diff --git a/api/core/types/task.go b/api/core/types/task.go index 599482af..afdcf504 100644 --- a/api/core/types/task.go +++ b/api/core/types/task.go @@ -70,17 +70,18 @@ type SdTaskParams struct { // DallTask DALL-E task type DallTask struct { - ModelId uint `json:"model_id"` - ModelName string `json:"model_name"` - Id uint `json:"id"` - UserId uint `json:"user_id"` - Prompt string `json:"prompt"` - N int `json:"n"` - Quality string `json:"quality"` - Size string `json:"size"` - Style string `json:"style"` - Power int `json:"power"` - TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID + ModelId uint `json:"model_id"` + ModelName string `json:"model_name"` + Image []string `json:"image,omitempty"` + Id uint `json:"id"` + UserId uint `json:"user_id"` + Prompt string `json:"prompt"` + N int `json:"n"` + Quality string `json:"quality"` + Size string `json:"size"` + Style string `json:"style"` + Power int `json:"power"` + TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID } type SunoTask struct { diff --git a/api/handler/admin/moderation_handler.go b/api/handler/admin/moderation_handler.go index c1d4b29c..86317332 100644 --- a/api/handler/admin/moderation_handler.go +++ b/api/handler/admin/moderation_handler.go @@ -244,7 +244,16 @@ func (h *ModerationHandler) UpdateModeration(c *gin.Context) { return } - err := h.DB.Where("name", types.ConfigKeyModeration).FirstOrCreate(&model.Config{Name: types.ConfigKeyModeration, Value: utils.JsonEncode(data)}).Error + var config model.Config + err := h.DB.Where("name", types.ConfigKeyModeration).First(&config).Error + if err != nil { + config.Name = types.ConfigKeyModeration + config.Value = utils.JsonEncode(data) + err = h.DB.Create(&config).Error + } else { + config.Value = utils.JsonEncode(data) + err = h.DB.Updates(&config).Error + } if err != nil { resp.ERROR(c, err.Error()) return diff --git a/api/handler/chat_handler.go b/api/handler/chat_handler.go index 9785a185..c44bb172 100644 --- a/api/handler/chat_handler.go +++ b/api/handler/chat_handler.go @@ -194,7 +194,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C } if userVo.Power < input.ChatModel.Power { - return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d,[立即购买](/member)。", userVo.Power, input.ChatModel.Power) + return fmt.Errorf("您的算力不足,请购买算力。") } if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() { @@ -338,16 +338,14 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C }, }) } else { - // 如果不是逆向模型,则提取文件内容 - modelValue := input.ChatModel.Value - if !(strings.Contains(modelValue, "-all") || strings.HasPrefix(modelValue, "gpt-4-gizmo") || strings.HasPrefix(modelValue, "claude")) { - content, err := utils.ReadFileContent(file.URL, h.App.Config.TikaHost) - if err != nil { - logger.Error("error with read file: ", err) - continue - } else { - fileContents = append(fileContents, fmt.Sprintf("%s 文件内容:%s", file.Name, content)) - } + // 处理文件,提取文件内容 + content, err := utils.ReadFileContent(file.URL, h.App.Config.TikaHost) + if err != nil { + logger.Error("error with read file: ", err) + continue + } else { + fileContents = append(fileContents, fmt.Sprintf("%s 文件内容:%s", file.Name, content)) + logger.Debugf("fileContents: %s", fileContents) } } } diff --git a/api/handler/dalle_handler.go b/api/handler/dalle_handler.go index 959b3298..5c34d613 100644 --- a/api/handler/dalle_handler.go +++ b/api/handler/dalle_handler.go @@ -118,6 +118,7 @@ func (h *DallJobHandler) Image(c *gin.Context) { UserId: uint(userId), ModelId: chatModel.Id, ModelName: chatModel.Value, + Image: data.Image, Prompt: data.Prompt, Quality: data.Quality, Size: data.Size, diff --git a/api/handler/user_handler.go b/api/handler/user_handler.go index 79fbb684..c70ecb90 100644 --- a/api/handler/user_handler.go +++ b/api/handler/user_handler.go @@ -112,8 +112,8 @@ func (h *UserHandler) Register(c *gin.Context) { return } - // 如果注册方式不是账号密码,则需要验证码 - if h.captchaService.GetConfig().Enabled && data.RegWay != "username" { + // 人机验证 + if h.captchaService.GetConfig().Enabled { var check bool if data.X != 0 { check = h.captchaService.SlideCheck(data) diff --git a/api/service/dalle/service.go b/api/service/dalle/service.go index 065e27e5..8dabfa70 100644 --- a/api/service/dalle/service.go +++ b/api/service/dalle/service.go @@ -16,6 +16,7 @@ import ( "geekai/store" "geekai/store/model" "geekai/utils" + "strings" "time" "github.com/go-redis/redis/v8" @@ -94,12 +95,14 @@ func (s *Service) Run() { } type imgReq struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - N int `json:"n,omitempty"` - Size string `json:"size,omitempty"` - Quality string `json:"quality,omitempty"` - Style string `json:"style,omitempty"` + Model string `json:"model"` + Image []string `json:"image,omitempty"` + Prompt string `json:"prompt"` + N int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + Style string `json:"style,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` } type imgRes struct { @@ -157,6 +160,11 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) { Style: task.Style, Quality: task.Quality, } + // 图片编辑 + if len(task.Image) > 0 { + reqBody.Prompt = fmt.Sprintf("%s, %s", strings.Join(task.Image, " "), task.Prompt) + } + logger.Infof("Channel:%s, API KEY:%s, BODY: %+v", apiURL, apiKey.Value, reqBody) r, err := s.httpClient.R().SetHeader("Body-Type", "application/json"). SetHeader("Authorization", "Bearer "+apiKey.Value). diff --git a/api/service/sms/service.go b/api/service/sms/service.go index 14d12ca9..82d0a914 100644 --- a/api/service/sms/service.go +++ b/api/service/sms/service.go @@ -7,8 +7,8 @@ package sms // * @Author yangjian102621@163.com // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -const Ali = "ALI" -const Bao = "BAO" +const Ali = "aliyun" +const Bao = "bao" type Service interface { SendVerifyCode(mobile string, code int) error diff --git a/web/src/assets/css/mobile/chat-session.scss b/web/src/assets/css/mobile/chat-session.scss index bea42876..41773c0d 100644 --- a/web/src/assets/css/mobile/chat-session.scss +++ b/web/src/assets/css/mobile/chat-session.scss @@ -49,6 +49,77 @@ } } } + + // .file-preview-list { + // display: flex; + // flex-wrap: wrap; + // padding: 6px 10px 0 10px; + // gap: 8px; + + // .file-preview-item { + // position: relative; + // display: inline-flex; + // align-items: center; + // border: 1px solid #e8e8e8; + // background: var(--van-cell-background); + // border-radius: 8px; + // padding: 6px 26px 6px 6px; + // overflow: hidden; + + // .thumb { + // width: 56px; + // height: 56px; + // border-radius: 6px; + // overflow: hidden; + + // .img { + // width: 56px; + // height: 56px; + // } + + // .size { + // position: absolute; + // left: 6px; + // bottom: 6px; + // background: rgba(0, 0, 0, 0.5); + // color: #fff; + // font-size: 10px; + // padding: 1px 4px; + // border-radius: 3px; + // } + // } + + // .doc { + // display: inline-flex; + // align-items: center; + // gap: 6px; + // max-width: 220px; + // .icon { + // width: 24px; + // height: 24px; + // } + // .name { + // white-space: nowrap; + // text-overflow: ellipsis; + // overflow: hidden; + // max-width: 180px; + // } + // .size { + // color: #8c8c8c; + // font-size: 12px; + // margin-left: 6px; + // } + // } + + // .remove { + // position: absolute; + // right: 6px; + // top: 6px; + // font-size: 14px; + // color: #999; + // } + // } + // } } } diff --git a/web/src/components/ChatPrompt.vue b/web/src/components/ChatPrompt.vue index 6fe20889..4b403646 100644 --- a/web/src/components/ChatPrompt.vue +++ b/web/src/components/ChatPrompt.vue @@ -232,6 +232,7 @@ const copyContent = (text) => { flex-flow: row; margin-right: 10px; position: relative; + justify-content: start; .el-image { border: 1px solid #e3e3e3; @@ -365,6 +366,7 @@ const copyContent = (text) => { flex-flow: row; margin-right: 10px; position: relative; + justify-content: end; .el-image { border: 1px solid #e3e3e3; diff --git a/web/src/components/FileList.vue b/web/src/components/FileList.vue index b0b739c0..0ecb5cee 100644 --- a/web/src/components/FileList.vue +++ b/web/src/components/FileList.vue @@ -81,6 +81,7 @@ const removeFile = (file) => { border: 1px solid #e3e3e3; padding: 6px; margin-right: 10px; + max-height: 54px; .icon { .el-image { @@ -91,10 +92,10 @@ const removeFile = (file) => { .body { margin-left: 5px; - font-size: 14px; + font-size: 12px; .title { - line-height: 24px; + // line-height: 20px; color: #0d0d0d; } diff --git a/web/src/components/ImageUpload.vue b/web/src/components/ImageUpload.vue index e1a3845c..26163cd8 100644 --- a/web/src/components/ImageUpload.vue +++ b/web/src/components/ImageUpload.vue @@ -85,7 +85,7 @@
拖拽图片到此处,或 点击上传
diff --git a/web/src/components/LoginDialog.vue b/web/src/components/LoginDialog.vue index 9c585d0f..9653df50 100644 --- a/web/src/components/LoginDialog.vue +++ b/web/src/components/LoginDialog.vue @@ -398,7 +398,7 @@ const activeName = ref('') const wxImg = ref('/images/wx.png') const captchaRef = ref(null) // eslint-disable-next-line no-undef -const emits = defineEmits(['hide', 'success']) +const emits = defineEmits(['hide', 'success', 'changeActive']) const action = ref('login') const enableCaptcha = ref(false) const captchaType = ref('') @@ -411,6 +411,13 @@ const showPrivacy = ref(false) const agreementHtml = ref('') const privacyHtml = ref('') +watch( + () => login.value, + (newValue) => { + emits('changeActive', newValue) + } +) + onMounted(() => { getSystemInfo() .then((res) => { @@ -649,7 +656,7 @@ const submitRegister = () => { if (!agreeChecked.value) { return ElMessage.error('请先阅读并同意《用户协议》和《隐私政策》') } - if (enableCaptcha.value && activeName.value === 'username') { + if (enableCaptcha.value) { captchaRef.value.loadCaptcha() action.value = 'register' } else { diff --git a/web/src/components/SendMsg.vue b/web/src/components/SendMsg.vue index 8149c63d..08ece2f2 100644 --- a/web/src/components/SendMsg.vue +++ b/web/src/components/SendMsg.vue @@ -4,15 +4,14 @@ {{ btnText }} - + @@ -201,6 +294,17 @@ onMounted(() => { } } } + + .action-buttons { + margin-top: 8px; + padding-left: 5px; + + .van-button { + font-size: 12px; + height: 24px; + padding: 0 8px; + } + } } } diff --git a/web/src/components/mobile/MobileFileList.vue b/web/src/components/mobile/MobileFileList.vue new file mode 100644 index 00000000..0e8befff --- /dev/null +++ b/web/src/components/mobile/MobileFileList.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/web/src/router.js b/web/src/router.js index 744defb3..f8f2bebf 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -134,7 +134,7 @@ const routes = [ name: 'register', path: '/register', meta: { title: '用户注册' }, - component: () => import('@/views/Register.vue'), + component: () => import('@/views/Login.vue'), }, { name: 'resetpassword', diff --git a/web/src/views/ChatPlus.vue b/web/src/views/ChatPlus.vue index a3cfc410..5a8becbf 100644 --- a/web/src/views/ChatPlus.vue +++ b/web/src/views/ChatPlus.vue @@ -384,7 +384,7 @@ import FileSelect from '@/components/FileSelect.vue' import Welcome from '@/components/Welcome.vue' import { checkSession, getClientId, getSystemInfo } from '@/store/cache' import { useSharedStore } from '@/store/sharedata' -import { closeLoading, showLoading, showMessageError } from '@/utils/dialog' +import { closeLoading, showLoading, showMessageError, showMessageInfo } from '@/utils/dialog' import { httpGet, httpPost } from '@/utils/http' import { isMobile, randString, removeArrayItem, UUID } from '@/utils/libs' import { @@ -789,6 +789,7 @@ const sendSSERequest = async (message) => { } } + // 回答完毕,更新完整的消息内容 if (data.type === 'complete') { chatData.value[chatData.value.length - 1] = data.body } @@ -1161,7 +1162,7 @@ const stopGenerate = function () { isGenerating.value = false httpGet('/api/chat/stop?session_id=' + getClientId()) .then(() => { - console.log('会话已中断') + showMessageInfo('会话已中断') }) .catch((e) => { showMessageError('中断对话失败:' + e.message) diff --git a/web/src/views/Dalle.vue b/web/src/views/Dalle.vue index 458a723c..50a16f58 100644 --- a/web/src/views/Dalle.vue +++ b/web/src/views/Dalle.vue @@ -101,6 +101,13 @@ 生成中... + +
+ +
+ +
+
@@ -295,6 +302,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { onMounted, onUnmounted, ref } from 'vue' import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next' import 'vue-waterfall-plugin-next/dist/style.css' +import ImageUpload from '@/components/ImageUpload.vue' const listBoxHeight = ref(0) // const paramBoxHeight = ref(0) diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue index 0f6810f2..9145c033 100644 --- a/web/src/views/Login.vue +++ b/web/src/views/Login.vue @@ -22,15 +22,22 @@ class="text-3xl font-semibold m-0 mb-2 tracking-tight" style="color: var(--login-title-color)" > - 欢迎登录 + {{ title }}

- 登录您的账户以继续使用服务 + {{ subtitle }}

- @@ -42,22 +49,22 @@ diff --git a/web/src/views/Register.vue b/web/src/views/Register.vue deleted file mode 100644 index d61f58f6..00000000 --- a/web/src/views/Register.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - - - diff --git a/web/src/views/mobile/ChatList.vue b/web/src/views/mobile/ChatList.vue index af796b56..6367c5f9 100644 --- a/web/src/views/mobile/ChatList.vue +++ b/web/src/views/mobile/ChatList.vue @@ -104,8 +104,12 @@ checkSession() .then((user) => { loginUser.value = user isLogin.value = true + }) + .finally(() => { + loading.value = false + finished.value = true // 加载角色列表 - httpGet(`/api/app/list/user`) + httpGet(`/api/app/list`) .then((res) => { if (res.data) { const items = res.data @@ -139,44 +143,6 @@ checkSession() showFailToast('加载模型失败: ' + e.message) }) }) - .catch(() => { - loading.value = false - finished.value = true - - // 加载角色列表 - httpGet('/api/app/list/user') - .then((res) => { - if (res.data) { - const items = res.data - for (let i = 0; i < items.length; i++) { - // console.log(items[i]) - roles.value.push({ - text: items[i].name, - value: items[i].id, - icon: items[i].icon, - helloMsg: items[i].hello_msg, - }) - } - } - }) - .catch(() => { - showFailToast('加载聊天角色失败') - }) - - // 加载模型 - httpGet('/api/model/list') - .then((res) => { - if (res.data) { - const items = res.data - for (let i = 0; i < items.length; i++) { - models.value.push({ text: items[i].name, value: items[i].id }) - } - } - }) - .catch((e) => { - showFailToast('加载模型失败: ' + e.message) - }) - }) const onLoad = () => { checkSession() diff --git a/web/src/views/mobile/ChatSession.vue b/web/src/views/mobile/ChatSession.vue index 825c0b16..e160d3bf 100644 --- a/web/src/views/mobile/ChatSession.vue +++ b/web/src/views/mobile/ChatSession.vue @@ -20,8 +20,6 @@ - -
@@ -50,43 +53,44 @@
- - + - +
- - - - - - - - - - - - -
@@ -114,19 +118,17 @@ import ChatReply from '@/components/mobile/ChatReply.vue' import { checkSession } from '@/store/cache' import { getUserToken } from '@/store/session' import { useSharedStore } from '@/store/sharedata' -import { showMessageError } from '@/utils/dialog' -import { httpGet } from '@/utils/http' +import { showMessageError, showLoading, closeLoading } from '@/utils/dialog' +import { httpGet, httpPost } from '@/utils/http' +import MobileFileList from '@/components/mobile/MobileFileList.vue' import { processContent, randString, renderInputText, UUID } from '@/utils/libs' import { fetchEventSource } from '@microsoft/fetch-event-source' -// 移除 Clipboard.js 相关内容 import hl from 'highlight.js' import 'highlight.js/styles/a11y-dark.css' -import MarkdownIt from 'markdown-it' -import emoji from 'markdown-it-emoji' -import mathjaxPlugin from 'markdown-it-mathjax3' import { showImagePreview, showNotify, showToast } from 'vant' import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' +import { getClientId } from '@/store/cache' const winHeight = ref(0) const navBarRef = ref(null) @@ -142,7 +144,6 @@ const modelValue = ref('') const title = ref(router.currentRoute.value.query['title']) const chatId = ref(router.currentRoute.value.query['chat_id']) const loginUser = ref(null) -// const showMic = ref(false) const showPicker = ref(false) const columns = ref([roles.value, models.value]) const selectedValues = ref([roleId.value, modelId.value]) @@ -218,44 +219,10 @@ const finished = ref(false) const error = ref(false) const store = useSharedStore() const url = ref(location.protocol + '//' + location.host + '/chat/export?chat_id=' + chatId.value) -const md = new MarkdownIt({ - breaks: true, - html: true, - linkify: true, - typographer: true, - highlight: function (str, lang) { - const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000) - // 显示复制代码按钮 - const copyBtn = `复制 -` - if (lang && hl.getLanguage(lang)) { - const langHtml = `${lang}` - // 处理代码高亮 - const preCode = hl.highlight(lang, str, true).value - // 将代码包裹在 pre 中 - return `
${preCode}${copyBtn} ${langHtml}
` - } - // 处理代码高亮 - const preCode = md.utils.escapeHtml(str) - // 将代码包裹在 pre 中 - return `
${preCode}${copyBtn}
` - }, -}) -md.use(mathjaxPlugin) -md.use(emoji) onMounted(() => { winHeight.value = window.innerHeight - navBarRef.value.$el.offsetHeight - bottomBarRef.value.$el.offsetHeight - 70 - - // 移除 Clipboard.js 相关内容 -}) - -onUnmounted(() => { - // Remove WebSocket handler cleanup }) const newChat = (item) => { @@ -272,10 +239,7 @@ const newChat = (item) => { } const onLoad = () => { - // checkSession().then(() => { - // connect() - // }).catch(() => { - // }) + // 加载更多消息的逻辑可以在这里实现 } const loadChatHistory = () => { @@ -294,6 +258,7 @@ const loadChatHistory = () => { text: role.hello_msg, }, orgContent: role.hello_msg, + showAction: false, }) return } @@ -303,19 +268,12 @@ const loadChatHistory = () => { chatData.value.push(data[i]) continue } - - data[i].orgContent = data[i].content - data[i].content.text = md.render(processContent(data[i].content.text)) + data[i].showAction = true + data[i].orgContent = data[i].content.text chatData.value.push(data[i]) } nextTick(() => { - hl.configure({ ignoreUnescapedHTML: true }) - const blocks = document.querySelector('#message-list-box').querySelectorAll('pre code') - blocks.forEach((block) => { - hl.highlightElement(block) - }) - scrollListBox() }) }) @@ -326,13 +284,12 @@ const loadChatHistory = () => { // 创建 socket 连接 const prompt = ref('') -const showStopGenerate = ref(false) // 停止生成 -const showReGenerate = ref(false) // 重新生成 const previousText = ref('') // 上一次提问 const lineBuffer = ref('') // 输出缓冲行 -const canSend = ref(true) +const isGenerating = ref(false) const isNewMsg = ref(true) const stream = ref(store.chatStream) +const abortController = new AbortController() watch( () => store.chatStream, (newValue) => { @@ -340,18 +297,6 @@ watch( } ) -const disableInput = (force) => { - canSend.value = false - showReGenerate.value = false - showStopGenerate.value = !force -} - -const enableInput = () => { - canSend.value = true - showReGenerate.value = previousText.value !== '' - showStopGenerate.value = false -} - // 将聊天框的滚动条滑动到最底部 const scrollListBox = () => { document @@ -359,9 +304,27 @@ const scrollListBox = () => { .scrollTo(0, document.getElementById('message-list-box').scrollHeight + 46) } +// 滚动到输入区域,确保预览文件可见 +const scrollToBottomBar = () => { + try { + // 优先让底部输入区域进入视野 + bottomBarRef.value && + bottomBarRef.value.$el && + bottomBarRef.value.$el.scrollIntoView({ + behavior: 'smooth', + block: 'end', + }) + } catch (e) {} + // 再兜底滚动到页面底部 + try { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }) + } catch (e) {} +} + // 发送 SSE 请求 const sendSSERequest = async (message) => { try { + isGenerating.value = true await fetchEventSource('/api/chat/message', { method: 'POST', headers: { @@ -369,6 +332,13 @@ const sendSSERequest = async (message) => { }, body: JSON.stringify(message), openWhenHidden: true, + // 重试机制,避免连接断开后一直重试 + retry: 3000, + // 设置重试延迟为0,确保不重试 + retryDelay: 3000, + // 设置最大重试次数为0 + maxRetries: 3, + signal: abortController.signal, onopen(response) { if (response.ok && response.status === 200) { console.log('SSE connection opened') @@ -380,13 +350,13 @@ const sendSSERequest = async (message) => { try { const data = JSON.parse(msg.data) if (data.type === 'error') { - showMessageError(data.body) - enableInput() + chatData.value[chatData.value.length - 1].error = data.body + isGenerating.value = false return } if (data.type === 'end') { - enableInput() + isGenerating.value = false lineBuffer.value = '' // 清空缓冲 isNewMsg.value = true return @@ -404,15 +374,9 @@ const sendSSERequest = async (message) => { lineBuffer.value += data.body const reply = chatData.value[chatData.value.length - 1] reply['orgContent'] = lineBuffer.value - reply['content']['text'] = md.render(processContent(lineBuffer.value)) + reply['content']['text'] = lineBuffer.value nextTick(() => { - hl.configure({ ignoreUnescapedHTML: true }) - const lines = document.querySelectorAll('.message-line') - const blocks = lines[lines.length - 1].querySelectorAll('pre code') - blocks.forEach((block) => { - hl.highlightElement(block) - }) scrollListBox() const items = document.querySelectorAll('.message-line') @@ -429,32 +393,49 @@ const sendSSERequest = async (message) => { }) } } + + // 回答完毕,更新完整的消息内容 + if (data.type === 'complete') { + data.body.showAction = true + data.body.orgContent = data.body.content.text + chatData.value[chatData.value.length - 1] = data.body + } } catch (error) { console.error('Error processing message:', error) - enableInput() + isGenerating.value = false showMessageError('消息处理出错,请重试') } }, onerror(err) { console.error('SSE Error:', err) - enableInput() + try { + abortController && abortController.abort() + } catch (e) { + console.error('AbortController abort error:', e) + } + isGenerating.value = false showMessageError('连接已断开,请重试') }, onclose() { console.log('SSE connection closed') - enableInput() + isGenerating.value = false }, }) } catch (error) { + try { + abortController && abortController.abort() + } catch (e) { + console.error('AbortController abort error:', e) + } console.error('Failed to send message:', error) - enableInput() + isGenerating.value = false showMessageError('发送消息失败,请重试') } } // 发送消息 const sendMessage = () => { - if (canSend.value === false) { + if (isGenerating.value) { showToast('AI 正在作答中,请稍后...') return } @@ -469,9 +450,78 @@ const sendMessage = () => { type: 'prompt', id: randString(32), icon: loginUser.value.avatar, - content: { text: renderInputText(prompt.value) }, + content: { text: renderInputText(prompt.value), files: files.value }, created_at: new Date().getTime(), }) + // 添加空回复消息 + const _role = getRoleById(roleId.value) + chatData.value.push({ + chat_id: chatId, + role_id: roleId.value, + type: 'reply', + id: randString(32), + icon: _role['icon'], + content: { + text: '', + files: [], + }, + }) + + nextTick(() => { + scrollListBox() + }) + + // 发送 SSE 请求 + sendSSERequest({ + user_id: loginUser.value.id, + role_id: roleId.value, + model_id: modelId.value, + chat_id: chatId.value, + prompt: prompt.value, + stream: stream.value, + files: files.value, + }) + + previousText.value = prompt.value + prompt.value = '' + files.value = [] + return true +} + +// 停止生成 +const stopGenerate = function () { + if (abortController) { + abortController.abort() + isGenerating.value = false + httpGet('/api/chat/stop?session_id=' + getClientId()) + .then(() => { + showToast('会话已中断') + }) + .catch((e) => { + showMessageError('中断对话失败:' + e.message) + }) + } +} + +// 处理从ChatReply组件触发的重新生成 +const handleRegenerate = (messageId) => { + if (isGenerating.value) { + showToast('AI 正在作答中,请稍后...') + return + } + + console.log('messageId', messageId) + console.log('chatData.value', chatData.value) + + // 判断 messageId 是整数 + if (messageId !== '' && isNaN(messageId)) { + showToast('消息 ID 不合法,无法重新生成') + return + } + + chatData.value = chatData.value.filter((item) => item.id < messageId && !item.isHello) + const userPrompt = chatData.value.pop() + // 添加空回复消息 const _role = getRoleById(roleId.value) chatData.value.push({ @@ -485,52 +535,19 @@ const sendMessage = () => { }, }) - nextTick(() => { - scrollListBox() - }) - - disableInput(false) - // 发送 SSE 请求 sendSSERequest({ user_id: loginUser.value.id, role_id: roleId.value, model_id: modelId.value, chat_id: chatId.value, - prompt: prompt.value, - stream: stream.value, - }) - - previousText.value = prompt.value - prompt.value = '' - return true -} - -// 重新生成 -const reGenerate = () => { - disableInput(false) - const text = '重新生成上述问题的答案:' + previousText.value - // 追加消息 - chatData.value.push({ - type: 'prompt', - id: randString(32), - icon: loginUser.value.avatar, - content: renderInputText(text), - }) - - // 发送 SSE 请求 - sendSSERequest({ - user_id: loginUser.value.id, - role_id: roleId.value, - model_id: modelId.value, - chat_id: chatId.value, - prompt: previousText.value, + last_msg_id: messageId, + prompt: userPrompt.content.text, stream: stream.value, + files: [], }) } -// 移除 showShare、shareOptions、shareChat 相关内容 - const getRoleById = function (rid) { for (let i = 0; i < roles.value.length; i++) { if (roles.value[i]['id'] === rid) { @@ -571,6 +588,38 @@ const copyShareUrl = async () => { showNotify({ type: 'danger', message: '复制失败', duration: 2000 }) } } + +// 文件上传与管理 +const files = ref([]) +const isHttpUrl = (url) => url.startsWith('http://') || url.startsWith('https://') +const toAbsUrl = (url) => (isHttpUrl(url) ? url : location.protocol + '//' + location.host + url) +const afterRead = async (fileItem) => { + showLoading('文件上传中...') + try { + const file = Array.isArray(fileItem) ? fileItem[0].file : fileItem.file || fileItem + const formData = new FormData() + formData.append('file', file, file.name) + const res = await httpPost('/api/upload', formData) + const f = res.data || {} + f.url = toAbsUrl(f.url || '') + files.value = [f, ...files.value] + // 确保上传后文件预览立即可见 + nextTick(() => { + scrollToBottomBar() + }) + } catch (e) { + showNotify({ type: 'danger', message: '文件上传失败:' + (e.message || '网络错误') }) + } finally { + closeLoading() + } +} +const removeFile = (f, idx) => { + files.value.splice(idx, 1) +} + +const onRemovePreview = ({ file, index }) => { + files.value.splice(index, 1) +}