feat: chat with file function is ready

This commit is contained in:
RockYang 2024-06-27 18:01:49 +08:00
parent 9343c73e0f
commit 6998dd7af4
14 changed files with 329 additions and 75 deletions

View File

@ -323,20 +323,46 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
reqMgs = append(reqMgs, m) reqMgs = append(reqMgs, m)
} }
fullPrompt := prompt
text := prompt
// extract files in prompt
files := utils.ExtractFileURLs(prompt)
logger.Debugf("detected FILES: %+v", files)
if len(files) > 0 {
contents := make([]string, 0)
var file model.File
for _, v := range files {
h.DB.Where("url = ?", v).First(&file)
content, err := utils.ReadFileContent(v)
if err == nil {
contents = append(contents, fmt.Sprintf("%s 文件内容:%s", file.Name, content))
}
text = strings.Replace(text, v, "", 1)
}
if len(contents) > 0 {
fullPrompt = fmt.Sprintf("请根据提供的文件内容信息回答问题(其中Excel 已转成 HTML)\n\n %s\n\n 问题:%s", strings.Join(contents, "\n"), text)
}
tokens, _ := utils.CalcTokens(fullPrompt, req.Model)
if tokens > session.Model.MaxContext {
return fmt.Errorf("文件的长度超出模型允许的最大上下文长度,请减少文件内容数量或文件大小。")
}
}
logger.Debug("最终Prompt", fullPrompt)
if session.Model.Platform == types.QWen.Value { if session.Model.Platform == types.QWen.Value {
req.Input = make(map[string]interface{}) req.Input = make(map[string]interface{})
reqMgs = append(reqMgs, types.Message{ reqMgs = append(reqMgs, types.Message{
Role: "user", Role: "user",
Content: prompt, Content: fullPrompt,
}) })
req.Input["messages"] = reqMgs req.Input["messages"] = reqMgs
} else if session.Model.Platform == types.OpenAI.Value || session.Model.Platform == types.Azure.Value { // extract image for gpt-vision model } else if session.Model.Platform == types.OpenAI.Value || session.Model.Platform == types.Azure.Value { // extract image for gpt-vision model
imgURLs := utils.ExtractImgURL(prompt) imgURLs := utils.ExtractImgURLs(prompt)
logger.Debugf("detected IMG: %+v", imgURLs) logger.Debugf("detected IMG: %+v", imgURLs)
var content interface{} var content interface{}
if len(imgURLs) > 0 { if len(imgURLs) > 0 {
data := make([]interface{}, 0) data := make([]interface{}, 0)
text := prompt
for _, v := range imgURLs { for _, v := range imgURLs {
text = strings.Replace(text, v, "", 1) text = strings.Replace(text, v, "", 1)
data = append(data, gin.H{ data = append(data, gin.H{
@ -352,7 +378,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
}) })
content = data content = data
} else { } else {
content = prompt content = fullPrompt
} }
req.Messages = append(reqMgs, map[string]interface{}{ req.Messages = append(reqMgs, map[string]interface{}{
"role": "user", "role": "user",
@ -361,7 +387,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
} else { } else {
req.Messages = append(reqMgs, map[string]interface{}{ req.Messages = append(reqMgs, map[string]interface{}{
"role": "user", "role": "user",
"content": prompt, "content": fullPrompt,
}) })
} }
@ -454,7 +480,7 @@ func (h *ChatHandler) StopGenerate(c *gin.Context) {
func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, session *types.ChatSession, apiKey *model.ApiKey) (*http.Response, error) { func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, session *types.ChatSession, apiKey *model.ApiKey) (*http.Response, error) {
// if the chat model bind a KEY, use it directly // if the chat model bind a KEY, use it directly
if session.Model.KeyId > 0 { if session.Model.KeyId > 0 {
h.DB.Debug().Where("id", session.Model.KeyId).Where("enabled", true).Find(apiKey) h.DB.Where("id", session.Model.KeyId).Find(apiKey)
} }
// use the last unused key // use the last unused key
if apiKey.Id == 0 { if apiKey.Id == 0 {

View File

@ -79,7 +79,7 @@ func (h *ChatHandler) sendXunFeiMessage(
var res *gorm.DB var res *gorm.DB
// use the bind key // use the bind key
if session.Model.KeyId > 0 { if session.Model.KeyId > 0 {
res = h.DB.Where("id", session.Model.KeyId).Where("enabled", true).Find(&apiKey) res = h.DB.Where("id", session.Model.KeyId).Find(&apiKey)
} }
// use the last unused key // use the last unused key
if apiKey.Id == 0 { if apiKey.Id == 0 {

View File

@ -215,7 +215,7 @@ func (h *MarkMapHandler) doRequest(req types.ApiRequest, chatModel model.ChatMod
// if the chat model bind a KEY, use it directly // if the chat model bind a KEY, use it directly
var res *gorm.DB var res *gorm.DB
if chatModel.KeyId > 0 { if chatModel.KeyId > 0 {
res = h.DB.Where("id", chatModel.KeyId).Where("enabled", true).Find(apiKey) res = h.DB.Where("id", chatModel.KeyId).Find(apiKey)
} }
// use the last unused key // use the last unused key
if apiKey.Id == 0 { if apiKey.Id == 0 {

View File

@ -7,7 +7,7 @@ import (
func main() { func main() {
file := "http://nk.img.r9it.com/chatgpt-plus/1719389335351828.xlsx" file := "http://nk.img.r9it.com/chatgpt-plus/1719389335351828.xlsx"
content, err := utils.ReadPdf(file) content, err := utils.ReadFileContent(file)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -13,7 +13,8 @@ import (
"github.com/google/go-tika/tika" "github.com/google/go-tika/tika"
) )
func ReadPdf(filePath string) (string, error) { func ReadFileContent(filePath string) (string, error) {
// for remote file, download it first
if strings.HasPrefix(filePath, "http") { if strings.HasPrefix(filePath, "http") {
file, err := downloadFile(filePath) file, err := downloadFile(filePath)
if err != nil { if err != nil {
@ -31,22 +32,34 @@ func ReadPdf(filePath string) (string, error) {
defer file.Close() defer file.Close()
// 使用 Tika 提取 PDF 文件的文本内容 // 使用 Tika 提取 PDF 文件的文本内容
html, err := client.Parse(context.TODO(), file) content, err := client.Parse(context.TODO(), file)
if err != nil { if err != nil {
return "", fmt.Errorf("error with parse file: %v", err) return "", fmt.Errorf("error with parse file: %v", err)
} }
fmt.Println(html) ext := filepath.Ext(filePath)
switch ext {
return cleanBlankLine(html), nil case ".doc", ".docx", ".pdf", ".pptx", "ppt":
return cleanBlankLine(cleanHtml(content, false)), nil
case ".xls", ".xlsx":
return cleanBlankLine(cleanHtml(content, true)), nil
default:
return cleanBlankLine(content), nil
}
} }
// 清理文本内容 // 清理文本内容
func cleanHtml(html string) string { func cleanHtml(html string, keepTable bool) string {
// 清理 HTML 标签 // 清理 HTML 标签
p := bluemonday.StrictPolicy() var policy *bluemonday.Policy
return p.Sanitize(html) if keepTable {
policy = bluemonday.NewPolicy()
policy.AllowElements("table", "thead", "tbody", "tfoot", "tr", "td", "th")
} else {
policy = bluemonday.StrictPolicy()
}
return policy.Sanitize(html)
} }
func cleanBlankLine(content string) string { func cleanBlankLine(content string) string {
@ -57,6 +70,12 @@ func cleanBlankLine(content string) string {
if len(line) < 2 { if len(line) < 2 {
continue continue
} }
// discard image
if strings.HasSuffix(line, ".png") ||
strings.HasSuffix(line, ".jpg") ||
strings.HasSuffix(line, ".jpeg") {
continue
}
texts = append(texts, line) texts = append(texts, line)
} }

View File

@ -88,7 +88,7 @@ func GetImgExt(filename string) string {
return ext return ext
} }
func ExtractImgURL(text string) []string { func ExtractImgURLs(text string) []string {
re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:png|jpg|jpeg|gif))`) re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:png|jpg|jpeg|gif))`)
matches := re.FindAllStringSubmatch(text, 10) matches := re.FindAllStringSubmatch(text, 10)
urls := make([]string, 0) urls := make([]string, 0)
@ -99,3 +99,15 @@ func ExtractImgURL(text string) []string {
} }
return urls return urls
} }
func ExtractFileURLs(text string) []string {
re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:docx?|pdf|pptx?|xlsx?|txt))`)
matches := re.FindAllStringSubmatch(text, 10)
urls := make([]string, 0)
if len(matches) > 0 {
for _, m := range matches {
urls = append(urls, m[1])
}
}
return urls
}

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
presets: [ presets: [
'@vue/cli-plugin-babel/preset' '@vue.css/cli-plugin-babel/preset'
] ]
} }

View File

@ -0,0 +1,237 @@
.chat-line {
ol, ul {
margin: 0.8em 0;
list-style: normal;
}
a {
color: #42b983;
font-weight: 600;
padding: 0 2px;
text-decoration: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
position: relative;
margin-top: 1rem;
margin-bottom: 1rem;
font-weight: bold;
line-height: 1.4;
cursor: text;
}
h1:hover a.anchor,
h2:hover a.anchor,
h3:hover a.anchor,
h4:hover a.anchor,
h5:hover a.anchor,
h6:hover a.anchor {
text-decoration: none;
}
h1 tt,
h1 code {
font-size: inherit !important;
}
h2 tt,
h2 code {
font-size: inherit !important;
}
h3 tt,
h3 code {
font-size: inherit !important;
}
h4 tt,
h4 code {
font-size: inherit !important;
}
h5 tt,
h5 code {
font-size: inherit !important;
}
h6 tt,
h6 code {
font-size: inherit !important;
}
h2 a,
h3 a {
color: #34495e;
}
h1 {
padding-bottom: .4rem;
font-size: 2.2rem;
line-height: 1.3;
}
h2 {
font-size: 1.75rem;
line-height: 1.225;
margin: 35px 0 15px;
padding-bottom: 0.5em;
border-bottom: 1px solid #ddd;
}
h3 {
font-size: 1.4rem;
line-height: 1.43;
margin: 20px 0 7px;
}
h4 {
font-size: 1.2rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 1rem;
color: #777;
}
p,
blockquote,
ul,
ol,
dl,
table {
margin: 0.8em 0;
}
li > ol,
li > ul {
margin: 0 0;
}
hr {
height: 2px;
padding: 0;
margin: 16px 0;
background-color: #e7e7e7;
border: 0 none;
overflow: hidden;
box-sizing: content-box;
}
body > h2:first-child {
margin-top: 0;
padding-top: 0;
}
body > h1:first-child {
margin-top: 0;
padding-top: 0;
}
body > h1:first-child + h2 {
margin-top: 0;
padding-top: 0;
}
body > h3:first-child,
body > h4:first-child,
body > h5:first-child,
body > h6:first-child {
margin-top: 0;
padding-top: 0;
}
a:first-child h1,
a:first-child h2,
a:first-child h3,
a:first-child h4,
a:first-child h5,
a:first-child h6 {
margin-top: 0;
padding-top: 0;
}
h1 p,
h2 p,
h3 p,
h4 p,
h5 p,
h6 p {
margin-top: 0;
}
li p.first {
display: inline-block;
}
ul,
ol {
padding-left: 30px;
}
ul:first-child,
ol:first-child {
margin-top: 0;
}
ul:last-child,
ol:last-child {
margin-bottom: 0;
}
blockquote {
border-left: 4px solid #42b983;
padding: 10px 15px;
color: #777;
background-color: rgba(66, 185, 131, .1);
}
table {
padding: 0;
word-break: initial;
}
table tr {
border-top: 1px solid #dfe2e5;
margin: 0;
padding: 0;
}
table tr:nth-child(2n),
thead {
background-color: #fafafa;
}
table tr th {
font-weight: bold;
border: 1px solid #dfe2e5;
border-bottom: 0;
text-align: left;
margin: 0;
padding: 6px 13px;
}
table tr td {
border: 1px solid #dfe2e5;
text-align: left;
margin: 0;
padding: 6px 13px;
}
table tr th:first-child,
table tr td:first-child {
margin-top: 0;
}
table tr th:last-child,
table tr td:last-child {
margin-bottom: 0;
}
}

View File

@ -16,7 +16,9 @@
<el-image :src="GetFileIcon(file.ext)" fit="cover" /> <el-image :src="GetFileIcon(file.ext)" fit="cover" />
</div> </div>
<div class="body"> <div class="body">
<div class="title">{{file.name}}</div> <div class="title">
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary:bold">{{file.name}}</el-link>
</div>
<div class="info"> <div class="info">
<span>{{GetFileType(file.ext)}}</span> <span>{{GetFileType(file.ext)}}</span>
<span>{{FormatFileSize(file.size)}}</span> <span>{{FormatFileSize(file.size)}}</span>
@ -121,6 +123,7 @@ onMounted(() => {
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/markdown/vue.css';
.chat-line-prompt { .chat-line-prompt {
background-color #ffffff; background-color #ffffff;
justify-content: center; justify-content: center;
@ -214,11 +217,6 @@ onMounted(() => {
margin 10px 0 margin 10px 0
} }
a {
color #20a0ff
}
p { p {
line-height 1.5 line-height 1.5
} }

View File

@ -97,6 +97,7 @@ const reGenerate = (prompt) => {
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/markdown/vue.css';
.common-layout { .common-layout {
.chat-line-reply { .chat-line-reply {
justify-content: center; justify-content: center;
@ -132,18 +133,12 @@ const reGenerate = (prompt) => {
.content { .content {
min-height 20px; min-height 20px;
word-break break-word; word-break break-word;
padding: 6px 10px; padding: 0 10px;
color #374151; color #374151;
font-size: var(--content-font-size); font-size: var(--content-font-size);
border-radius: 5px; border-radius: 5px;
overflow auto; overflow auto;
a {
color #20a0ff
}
// control the image size in content
img { img {
max-width: 600px; max-width: 600px;
border-radius: 10px; border-radius: 10px;
@ -170,10 +165,11 @@ const reGenerate = (prompt) => {
.code-container { .code-container {
position relative position relative
display flex
.hljs { .hljs {
border-radius 10px border-radius 10px
line-height 1.5 width 100%
} }
.copy-code-btn { .copy-code-btn {
@ -194,7 +190,7 @@ const reGenerate = (prompt) => {
.lang-name { .lang-name {
position absolute; position absolute;
right 10px right 10px
bottom 50px bottom 20px
padding 2px 6px 4px 6px padding 2px 6px 4px 6px
background-color #444444 background-color #444444
border-radius 10px border-radius 10px

View File

@ -20,6 +20,7 @@
:auto-upload="true" :auto-upload="true"
:show-file-list="false" :show-file-list="false"
:http-request="afterRead" :http-request="afterRead"
accept=".doc,.docx,.jpg,.png,.jpeg,.xls,.xlsx,.ppt,.pptx,.pdf"
> >
<el-icon class="avatar-uploader-icon"> <el-icon class="avatar-uploader-icon">
<Plus/> <Plus/>

View File

@ -188,38 +188,7 @@ export function processContent(content) {
} }
} }
} }
return content
const lines = content.split("\n")
if (lines.length <= 1) {
return content
}
const texts = []
// 定义匹配数学公式的正则表达式
const formulaRegex = /^\s*[a-z|A-Z]+[^=]+\s*=\s*[^=]+$/;
let hasCode = false
for (let i = 0; i < lines.length; i++) {
// 处理引用块换行
if (lines[i].startsWith(">")) {
texts.push(lines[i])
texts.push("\n")
continue
}
// 如果包含代码块则跳过公式检测
if (lines[i].indexOf("```") !== -1) {
texts.push(lines[i])
hasCode = true
continue
}
// 识别并处理数学公式,需要排除那些已经被识别出来的公式
if (i > 0 && formulaRegex.test(lines[i]) && lines[i - 1].indexOf("$$") === -1 && !hasCode) {
texts.push("$$")
texts.push(lines[i])
texts.push("$$")
continue
}
texts.push(lines[i])
}
return texts.join("\n")
} }
export function processPrompt(prompt) { export function processPrompt(prompt) {

View File

@ -24,7 +24,7 @@
<div class="content" :style="{height: leftBoxHeight+'px'}"> <div class="content" :style="{height: leftBoxHeight+'px'}">
<el-row v-for="chat in chatList" :key="chat.chat_id"> <el-row v-for="chat in chatList" :key="chat.chat_id">
<div :class="chat.chat_id === activeChat.chat_id?'chat-list-item active':'chat-list-item'" <div :class="chat.chat_id === activeChat.chat_id?'chat-list-item active':'chat-list-item'"
@click="changeChat(chat)"> @click="loadChat(chat)">
<el-image :src="chat.icon" class="avatar"/> <el-image :src="chat.icon" class="avatar"/>
<span class="chat-title-input" v-if="chat.edit"> <span class="chat-title-input" v-if="chat.edit">
<el-input v-model="tmpChatTitle" size="small" @keydown="titleKeydown($event, chat)" <el-input v-model="tmpChatTitle" size="small" @keydown="titleKeydown($event, chat)"
@ -424,12 +424,8 @@ const newChat = () => {
connect(null, roleId.value) connect(null, roleId.value)
} }
//
const changeChat = (chat) => {
localStorage.setItem("chat_id", chat.chat_id)
loadChat(chat)
}
//
const loadChat = function (chat) { const loadChat = function (chat) {
if (!isLogin.value) { if (!isLogin.value) {
store.setShowLoginDialog(true) store.setShowLoginDialog(true)
@ -753,7 +749,7 @@ const sendMessage = function () {
// //
let content = prompt.value let content = prompt.value
if (files.value.length > 0) { if (files.value.length > 0) {
content = files.value.map(file => file.url).join(" ") + " " + content content += files.value.map(file => file.url).join(" ")
} }
// //
chatData.value.push({ chatData.value.push({

View File

@ -162,7 +162,7 @@
</el-form-item> </el-form-item>
<el-form-item label="绑定API-KEY" prop="apikey"> <el-form-item label="绑定API-KEY" prop="apikey">
<el-select v-model="item.key_id" placeholder="请选择 API KEY" clearable> <el-select v-model="item.key_id" placeholder="请选择 API KEY" filterable clearable>
<el-option v-for="v in apiKeys" :value="v.id" :label="v.name" :key="v.id"> <el-option v-for="v in apiKeys" :value="v.id" :label="v.name" :key="v.id">
{{ v.name }} {{ v.name }}
<el-text type="info" size="small">{{ substr(v.api_url, 50) }}</el-text> <el-text type="info" size="small">{{ substr(v.api_url, 50) }}</el-text>
@ -229,7 +229,7 @@ const platforms = ref([])
// API KEY // API KEY
const apiKeys = ref([]) const apiKeys = ref([])
httpGet('/api/admin/apikey/list?status=true&type=chat').then(res => { httpGet('/api/admin/apikey/list?type=chat').then(res => {
apiKeys.value = res.data apiKeys.value = res.data
}).catch(e => { }).catch(e => {
ElMessage.error("获取 API KEY 失败:" + e.message) ElMessage.error("获取 API KEY 失败:" + e.message)