From 4083de9acdde9c0a46a45fb5f4ae5327c687f181 Mon Sep 17 00:00:00 2001 From: wangdan-fit2cloud Date: Mon, 28 Sep 2026 17:14:24 +0800 Subject: [PATCH] feat: chat message input --- ui/src/components.d.ts | 8 + ui/src/components/COMPONENT_README.md | 50 +++++- .../global/mk-horizontal-scroll/index.vue | 75 +++++++++ .../global/mk-media-preview/MkAudio.vue | 18 +++ .../global/mk-media-preview/MkVideo.vue | 26 ++++ .../global/mk-media-preview/index.vue | 76 +++++++++ .../global/mk-media-preview/types.ts | 5 + ui/src/components/mk-tags-edit/index.vue | 17 +- ui/src/constants/CONSTANT_README.md | 3 + ui/src/constants/file-type.ts | 5 + .../components/message-input/index.ts | 147 ++++++++---------- .../components/message-input/index.vue | 90 +++++------ .../conversation-panel/core/layout/index.vue | 21 ++- ui/src/styles/STYLE_README.md | 3 + ui/src/styles/app.scss | 7 - ui/src/styles/element-plus.scss | 7 + ui/src/utils/UTILS_README.md | 10 +- ui/src/utils/icon.ts | 53 ++++--- .../chat-log/ChatLogDetailDrawer.vue | 8 +- .../ExecutionDetailDrawer.vue | 8 +- .../ExecutionDetailDrawer.vue | 8 +- .../ExecutionDetailDrawer.vue | 8 +- ui/src/workflow-canvas/WORKFLOW_README.md | 4 +- .../base-node/component/FileUploadSetting.vue | 27 ++-- 24 files changed, 480 insertions(+), 204 deletions(-) create mode 100644 ui/src/components/global/mk-horizontal-scroll/index.vue create mode 100644 ui/src/components/global/mk-media-preview/MkAudio.vue create mode 100644 ui/src/components/global/mk-media-preview/MkVideo.vue create mode 100644 ui/src/components/global/mk-media-preview/index.vue create mode 100644 ui/src/components/global/mk-media-preview/types.ts create mode 100644 ui/src/constants/file-type.ts diff --git a/ui/src/components.d.ts b/ui/src/components.d.ts index eb45cccdcb9..479856cc62f 100644 --- a/ui/src/components.d.ts +++ b/ui/src/components.d.ts @@ -21,6 +21,7 @@ declare module 'vue' { MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default'] MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default'] MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default'] + MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default'] MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default'] MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default'] MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default'] @@ -30,9 +31,11 @@ declare module 'vue' { MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default'] MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default'] MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default'] + MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default'] MkIcon: typeof import('./components/global/mk-icon/index.vue')['default'] MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default'] MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default'] + MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default'] MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default'] MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default'] MkSlider: typeof import('./components/global/mk-slider/index.vue')['default'] @@ -45,6 +48,7 @@ declare module 'vue' { MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default'] MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default'] MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default'] + MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.vue')['default'] MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default'] PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -69,6 +73,7 @@ declare global { const MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default'] const MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default'] const MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default'] + const MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default'] const MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default'] const MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default'] const MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default'] @@ -78,9 +83,11 @@ declare global { const MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default'] const MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default'] const MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default'] + const MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default'] const MkIcon: typeof import('./components/global/mk-icon/index.vue')['default'] const MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default'] const MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default'] + const MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default'] const MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default'] const MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default'] const MkSlider: typeof import('./components/global/mk-slider/index.vue')['default'] @@ -93,6 +100,7 @@ declare global { const MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default'] const MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default'] const MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default'] + const MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.vue')['default'] const MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default'] const PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/ui/src/components/COMPONENT_README.md b/ui/src/components/COMPONENT_README.md index 16ef8065d62..0c3ee5232e4 100644 --- a/ui/src/components/COMPONENT_README.md +++ b/ui/src/components/COMPONENT_README.md @@ -75,6 +75,47 @@ import { MkDynamicsForm, MkDynamicsFormConstructor } from '@/components/mk-dynam ## 全局 UI 组件 +### MkHorizontalScroll + +全局横向滚动组件,位于 `global/mk-horizontal-scroll/index.vue`,模板自动注册。 +通过默认插槽传入内容,内部横向 Flex 排列并提供 8px 间距;固定宽度的内容应设置 `shrink-0`。 +左右箭头分别在对应方向可滚动时显示,到达边界即隐藏;按钮绝对定位在容器两侧并垂直居中, +不占用内容宽度。每次点击平滑滚动可视宽度的 80%。 +底层使用 `el-scrollbar`,隐藏横向滚动条,保留触控板和触屏的原生横向滚动。 +`ResizeObserver` 监听容器和内容尺寸,自动响应内容增删、图片加载及窗口缩放;卸载时清理监听。 +外部间距由使用方通过 `class` 设置,不在组件内绑定附件业务。 + +```vue + + + +``` + +### MkAudio、MkVideo + +位于 `global/mk-media-preview/`,两个全局组件分别提供音频、视频遮罩播放器。 +传入必填 `src`、`name`;通过默认插槽传入触发内容,卡片、图标、文件大小及样式由调用方维护。 +`src` 为空时不打开预览;触发区域支持点击、Enter 和空格打开,遮罩内居中显示原生音频或视频控制条并尝试自动播放; +自动播放受浏览器策略限制时可手动播放。关闭按钮、点击遮罩或 Esc 关闭预览,立即卸载播放器。 +附件删除或列表清空导致组件卸载时也会清理播放器;地址变化时关闭旧预览。 +预览采用页面遮罩,不主动请求浏览器全屏,视频原生控制条仍可提供全屏功能。 +内部入口 `index.vue`(`MkMediaPreview`) 统一无样式的触发容器与媒体遮罩,不使用 `MkDialog`。 +遮罩通过 Teleport 挂载到 body,使用 `ElOverlay` 与 `useZIndex` 管理遮罩和层级, +`useLockscreen` 锁定背景滚动,`TrapFocus` 约束键盘焦点;打开时聚焦关闭按钮,关闭后焦点返回触发区。 +预览区处理 Esc 关闭,界面只展示居中的播放器和右上角关闭按钮。业务方使用两个公开组件, +不直接调用内部组件。组件不创建或释放 Object URL,由调用方维护地址生命周期。 + +```vue + + 录音附件卡片 + + + 视频附件卡片 + +``` + ### MkTooltip 普通提示统一使用 `MkTooltip`,业务代码不直接使用 `el-tooltip`。默认悬停 300ms 后显示, @@ -549,10 +590,11 @@ const avatar = ref('') ### MkTagsEdit 手动导入 `@/components/mk-tags-edit/index.vue`,通过必填的 `string[]` 类型 `v-model` -编辑标签。可选 `reservedTags` 接收保留标签数组,默认为空;组件检查保留值与当前列表的 -重复项,并提示“该标签已存在”。输入失焦或按回车时去除首尾空格,空值不添加;默认保留大小写 -和前导点。业务需要格式化时通过 `normalizeTag(tag)` 传入转换函数,在重复检查前执行。 -`addText` 默认为“添加标签”。组件内部维护输入框显隐与自动聚焦,根节点阻止点击冒泡;外部间距通过 `class` 设置。 +编辑文件后缀。可选 `presetExtensions` 接收预设文件后缀数组,默认为空。 +输入失焦或按回车时,组件内部去除首尾空格和一个前导点,并转为大写,空值不添加; +重复检查对预设后缀与当前列表应用相同规则,兼容已有的小写后缀,重复时提示“文件后缀已存在”。 +已有数据不自动改写,新添加的后缀按上述格式保存。添加按钮和输入提示固定为“添加后缀名”。 +组件内部维护输入框显隐与自动聚焦,根节点阻止点击冒泡;外部间距通过 `class` 设置。 ### MkCardCheckbox diff --git a/ui/src/components/global/mk-horizontal-scroll/index.vue b/ui/src/components/global/mk-horizontal-scroll/index.vue new file mode 100644 index 00000000000..bc4d0b98b31 --- /dev/null +++ b/ui/src/components/global/mk-horizontal-scroll/index.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/ui/src/components/global/mk-media-preview/MkAudio.vue b/ui/src/components/global/mk-media-preview/MkAudio.vue new file mode 100644 index 00000000000..8794ef65de5 --- /dev/null +++ b/ui/src/components/global/mk-media-preview/MkAudio.vue @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/components/global/mk-media-preview/MkVideo.vue b/ui/src/components/global/mk-media-preview/MkVideo.vue new file mode 100644 index 00000000000..975169a4887 --- /dev/null +++ b/ui/src/components/global/mk-media-preview/MkVideo.vue @@ -0,0 +1,26 @@ + + + diff --git a/ui/src/components/global/mk-media-preview/index.vue b/ui/src/components/global/mk-media-preview/index.vue new file mode 100644 index 00000000000..be7cb68543f --- /dev/null +++ b/ui/src/components/global/mk-media-preview/index.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/ui/src/components/global/mk-media-preview/types.ts b/ui/src/components/global/mk-media-preview/types.ts new file mode 100644 index 00000000000..55fea8119a4 --- /dev/null +++ b/ui/src/components/global/mk-media-preview/types.ts @@ -0,0 +1,5 @@ +/** 音视频遮罩预览的公共参数。 */ +export interface MediaPreviewProps { + src: string + name: string +} diff --git a/ui/src/components/mk-tags-edit/index.vue b/ui/src/components/mk-tags-edit/index.vue index da284545d62..23508142941 100644 --- a/ui/src/components/mk-tags-edit/index.vue +++ b/ui/src/components/mk-tags-edit/index.vue @@ -6,8 +6,8 @@ import { MsgWarning } from '@/utils/message' defineOptions({ name: 'MkTagsEdit' }) const tags = defineModel({ required: true }) -const props = withDefaults(defineProps<{ reservedTags?: readonly string[]; normalizeTag?: (tag: string) => string }>(), { - reservedTags: () => [], +const props = withDefaults(defineProps<{ presetExtensions?: readonly string[] }>(), { + presetExtensions: () => [], }) // 标签编辑:自动聚焦,确认时清理输入并检查重复值。 @@ -20,11 +20,17 @@ function showTagInput() { nextTick(() => inputRef.value?.focus()) } +function normalizeExtension(extension: string) { + return extension.trim().replace(/^\./, '').toUpperCase() +} + function confirmTag() { - const input = tagInput.value.trim() - const tag = props.normalizeTag ? props.normalizeTag(input) : input + const tag = normalizeExtension(tagInput.value) if (tag) { - if (props.reservedTags.includes(tag) || tags.value.includes(tag)) { + if ( + props.presetExtensions.some((presetExtension) => normalizeExtension(presetExtension) === tag) || + tags.value.some((currentTag) => normalizeExtension(currentTag) === tag) + ) { MsgWarning('文件后缀已存在') } else { tags.value = [...tags.value, tag] @@ -57,6 +63,7 @@ function removeTag(tag: string) { @blur="confirmTag" @keyup.enter="confirmTag" /> + 添加后缀名 diff --git a/ui/src/constants/CONSTANT_README.md b/ui/src/constants/CONSTANT_README.md index d51d84f5b7c..8fbd0ac817b 100644 --- a/ui/src/constants/CONSTANT_README.md +++ b/ui/src/constants/CONSTANT_README.md @@ -45,3 +45,6 @@ const knowledgeTypeLabel = KNOWLEDGE_TYPE_LABELS[knowledgeType] `document.ts` 的 `DOCUMENT_HIT_HANDLING_LABELS` 维护文档命中处理方式的展示文案, 映射键引用 API 枚举 `DOCUMENT_HIT_HANDLING`。 + +`file-type.ts` 维护图片、文档、视频和音频的文件扩展名常量,扩展名统一使用大写。 +使用方判断文件类型时先将后缀转换为大写,再与对应常量匹配。 diff --git a/ui/src/constants/file-type.ts b/ui/src/constants/file-type.ts new file mode 100644 index 00000000000..9d40bd58254 --- /dev/null +++ b/ui/src/constants/file-type.ts @@ -0,0 +1,5 @@ +// 文件类型对应的扩展名,统一使用大写。 +export const IMAGE_EXTENSIONS: readonly string[] = ['JPG', 'JPEG', 'PNG', 'GIF', 'BMP'] +export const DOCUMENT_EXTENSIONS: readonly string[] = ['PDF', 'DOCX', 'TXT', 'XLS', 'XLSX', 'MD', 'HTML', 'CSV'] +export const VIDEO_EXTENSIONS: readonly string[] = ['MP4', 'AVI', 'MKV', 'MOV', 'FLV', 'WMV'] +export const AUDIO_EXTENSIONS: readonly string[] = ['MP3', 'WAV', 'OGG', 'AAC', 'M4A'] diff --git a/ui/src/conversation-panel/components/message-input/index.ts b/ui/src/conversation-panel/components/message-input/index.ts index d84640ca4bb..47babd83b1f 100644 --- a/ui/src/conversation-panel/components/message-input/index.ts +++ b/ui/src/conversation-panel/components/message-input/index.ts @@ -1,16 +1,8 @@ import { ref, computed, reactive, watch, inject, type Ref, type InjectionKey } from 'vue' +import { IMAGE_EXTENSIONS, DOCUMENT_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } from '@/constants/file-type' +import { isImage, isDocument, isAudio, isVideo } from '@/utils/icon' import type { ChatMessage, Conversation, SendMessageOptions } from '../../core/types' -const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] -const documentExts = ['pdf', 'docx', 'txt', 'xls', 'xlsx', 'md', 'html', 'csv'] -const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'flv', 'wmv'] -const audioExts = ['mp3', 'wav', 'ogg', 'aac', 'm4a'] -const getExt = (name: string) => name.split('.').pop()?.toLowerCase() || '' -const isImage = (name: string) => imageExts.includes(getExt(name)) -const isDocument = (name: string) => documentExts.includes(getExt(name)) -const isAudio = (name: string) => audioExts.includes(getExt(name)) -const isVideo = (name: string) => videoExts.includes(getExt(name)) - export interface FileItem { uid: number name: string @@ -22,7 +14,14 @@ export interface FileItem { uploading?: boolean } -// 只声明本组件真正用到的依赖(接口隔离),不接收整个别人的 store +type MessageAttachmentKey = 'image_list' | 'document_list' | 'audio_list' | 'video_list' | 'other_list' +type MessageAttachment = Pick +type QuestionMessage = { + type: 'QUESTION' + content: string +} & Partial> + +// 仅注入消息输入所需的会话、上传和发送能力。 export interface MessageInputDeps { // 来自 conversation-list conversations: Ref @@ -41,70 +40,57 @@ export interface MessageInputDeps { * message-input 组件 store:本组件独有——输入内容 + 暂存文件 + 发送。 */ export function createMessageInputStore(deps: MessageInputDeps) { - const { conversations, getChatId, renameChat, composerResetSignal, messages, loading, sendMessage, uploadFile, stop } = - deps + const { conversations, getChatId, renameChat, composerResetSignal, messages, loading, sendMessage, uploadFile, stop } = deps + // 输入状态与附件限制 const question = ref('') const fileList = ref([]) - const uploadPromises = ref[]>([]) - const showDelete = ref('') const sending = ref(false) const maxFiles = 10 const maxSizeMB = 50 - const acceptList = [...imageExts, ...documentExts, ...videoExts, ...audioExts].map((e) => `.${e}`).join(',') + + // TODO 格式 不行 + const acceptList = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS].map((e) => `.${e}`).join(',') const placeholder = computed(() => (loading.value ? '正在回复中...' : '输入消息...')) - const canSend = computed( - () => (question.value.trim() || fileList.value.length > 0) && !loading.value && !sending.value, - ) + const canSend = computed(() => (question.value.trim().length > 0 || fileList.value.length > 0) && !loading.value && !sending.value) - const imageFiles = computed(() => fileList.value.filter((f) => isImage(f.name))) - const documentFiles = computed(() => fileList.value.filter((f) => isDocument(f.name))) - const audioFiles = computed(() => fileList.value.filter((f) => isAudio(f.name))) - const videoFiles = computed(() => fileList.value.filter((f) => isVideo(f.name))) - - const getFileIcon = (name: string) => { - const ext = getExt(name) - const doc = 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/svg/document.svg' - const audio = 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/svg/headset.svg' - const iconMap: Record = { - pdf: doc, doc, docx: doc, xls: doc, xlsx: doc, txt: doc, md: doc, html: doc, csv: doc, - mp3: audio, wav: audio, ogg: audio, aac: audio, m4a: audio, - } - return iconMap[ext] || doc - } + const imageFiles = computed(() => fileList.value.filter((attachment) => isImage(attachment.name))) + + // 附件上传与本地预览 + const uploadPromises = ref[]>([]) - const validateFile = (file: File) => - fileList.value.length < maxFiles && file.size > 0 && file.size <= maxSizeMB * 1024 * 1024 + const validateFile = (file: File) => fileList.value.length < maxFiles && file.size > 0 && file.size <= maxSizeMB * 1024 * 1024 const addFile = (file: File) => { if (!validateFile(file)) return - const item: FileItem = reactive({ + const attachment: FileItem = reactive({ uid: Date.now() + Math.random(), name: file.name, size: file.size, raw: file, uploading: true, }) - if (isImage(file.name)) item.previewUrl = URL.createObjectURL(file) - fileList.value.push(item) + if (isImage(file.name) || isAudio(file.name) || isVideo(file.name)) { + attachment.previewUrl = URL.createObjectURL(file) + } + fileList.value.push(attachment) const uploadPromise = (async () => { try { - const cid = getChatId() - const url = await uploadFile(file, cid) - item.url = url - const parts = url.split('/') - item.file_id = parts[parts.length - 1] + const chatId = getChatId() + const url = await uploadFile(file, chatId) + attachment.url = url + attachment.file_id = url.split('/').pop() } catch (e) { console.error('upload failed:', e) } finally { - item.uploading = false + attachment.uploading = false } })() uploadPromises.value.push(uploadPromise) uploadPromise.finally(() => { - uploadPromises.value = uploadPromises.value.filter((p) => p !== uploadPromise) + uploadPromises.value = uploadPromises.value.filter((pendingUpload) => pendingUpload !== uploadPromise) }) } @@ -114,61 +100,57 @@ export function createMessageInputStore(deps: MessageInputDeps) { } const removeFile = (index: number) => { - const item = fileList.value[index] - if (!item) return - if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) + const attachment = fileList.value[index] + if (!attachment) return + if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl) fileList.value.splice(index, 1) } const clearFiles = () => { - fileList.value.forEach((f) => { - if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + fileList.value.forEach((attachment) => { + if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl) }) fileList.value = [] } + // 等待附件上传后发送,首条消息同步会话标题。 const send = async () => { - if (sending.value || !canSend.value) return + if (!canSend.value) return sending.value = true try { if (uploadPromises.value.length) await Promise.all(uploadPromises.value) - const text = question.value.trim() - const cid = getChatId() + const questionText = question.value.trim() + const chatId = getChatId() - // 首条消息:此刻把会话 push 进左侧列表(标题乐观更新),流结束后 renameChat 落库 + // 首条消息先显示会话标题,流结束后再保存标题。 const isFirstMessage = messages.value.length === 0 - const abstract = text.substring(0, 256) - if (isFirstMessage && !conversations.value.some((c: any) => c.id === cid)) { - conversations.value.unshift({ id: cid, abstract: text ? abstract : '新对话' }) + const abstract = questionText.substring(0, 256) + if (isFirstMessage && !conversations.value.some((conversation) => conversation.id === chatId)) { + conversations.value.unshift({ id: chatId, abstract: questionText ? abstract : '新对话' }) } - const pick = (pred: (n: string) => boolean) => - fileList.value.filter((f) => pred(f.name)).map((f) => ({ url: f.url, file_id: f.file_id, name: f.name })) - const images = pick(isImage) - const documents = pick(isDocument) - const audio = pick(isAudio) - const video = pick(isVideo) - const other = fileList.value - .filter((f) => !isImage(f.name) && !isDocument(f.name) && !isAudio(f.name) && !isVideo(f.name)) - .map((f) => ({ url: f.url, file_id: f.file_id, name: f.name })) - - const questionContent: any = { type: 'QUESTION', content: text } - const message: any = { content: text, type: 'QUESTION' } - if (images.length) questionContent.image_list = message.image_list = images - if (documents.length) questionContent.document_list = message.document_list = documents - if (audio.length) questionContent.audio_list = message.audio_list = audio - if (video.length) questionContent.video_list = message.video_list = video - if (other.length) questionContent.other_list = message.other_list = other + // 一次遍历完成附件分类,仅提交有附件的字段。 + const message: QuestionMessage = { type: 'QUESTION', content: questionText } + for (const attachment of fileList.value) { + let attachmentKey: MessageAttachmentKey = 'other_list' + if (isImage(attachment.name)) attachmentKey = 'image_list' + else if (isDocument(attachment.name)) attachmentKey = 'document_list' + else if (isAudio(attachment.name)) attachmentKey = 'audio_list' + else if (isVideo(attachment.name)) attachmentKey = 'video_list' + + const attachments = (message[attachmentKey] ??= []) + attachments.push({ url: attachment.url, file_id: attachment.file_id, name: attachment.name }) + } sendMessage({ - chatId: cid, + chatId, message, newQuestion: true, - questionContent, + questionContent: { ...message }, reChat: false, onComplete: (e) => { - if (!e && isFirstMessage && text) renameChat(cid, abstract).catch(() => {}) + if (!e && isFirstMessage && questionText) renameChat(chatId, abstract).catch(() => {}) }, }) question.value = '' @@ -193,23 +175,18 @@ export function createMessageInputStore(deps: MessageInputDeps) { return { question, fileList, - showDelete, maxFiles, maxSizeMB, acceptList, - loading: loading, + loading, placeholder, canSend, imageFiles, - documentFiles, - audioFiles, - videoFiles, - getFileIcon, addFile, addFiles, removeFile, send, - stop: stop, + stop, } } diff --git a/ui/src/conversation-panel/components/message-input/index.vue b/ui/src/conversation-panel/components/message-input/index.vue index 454b7fa2d65..fe0e95ca688 100644 --- a/ui/src/conversation-panel/components/message-input/index.vue +++ b/ui/src/conversation-panel/components/message-input/index.vue @@ -1,48 +1,34 @@