diff --git a/ui/src/assets/iconfont.js b/ui/src/assets/iconfont.js index 5a8dac930f0..7fd69669718 100644 --- a/ui/src/assets/iconfont.js +++ b/ui/src/assets/iconfont.js @@ -1 +1 @@ -window._iconfont_svg_string_5200172='',(h=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,v,t,i,c,d=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,l=document.createElement("div");l.innerHTML=h._iconfont_svg_string_5200172,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?d(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),o()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(t=o,i=h.document,c=!1,e(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,m())})}function m(){c||(c=!0,t())}function e(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(e,50)}m()}})(window); \ No newline at end of file +window._iconfont_svg_string_5200172='',(h=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,v,t,i,c,d=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,l=document.createElement("div");l.innerHTML=h._iconfont_svg_string_5200172,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?d(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),o()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(t=o,i=h.document,c=!1,m(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,e())})}function e(){c||(c=!0,t())}function m(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(m,50)}e()}})(window); \ No newline at end of file diff --git a/ui/src/components/COMPONENT_README.md b/ui/src/components/COMPONENT_README.md index a14e49a8120..d06771ab944 100644 --- a/ui/src/components/COMPONENT_README.md +++ b/ui/src/components/COMPONENT_README.md @@ -539,17 +539,18 @@ Logo 数据统一从 Theme Store 获取。 ### MkDragUpload 入口 `mk-drag-upload/index.vue`,内部按单/多文件分离实现。`v-model` 为 `DragUploadFile[]`, -兼容 UploadUserFile,支持 file_id、errMsg、canRetry;类型从组件 `types.ts` 导入。 +兼容 UploadUserFile,支持 file_id、errMsg;类型从组件 `types.ts` 导入。 - `multiple` 默认 false;单文件展示卡片和更换入口,多文件保留拖拽区、文件夹选择与列表。 - 多文件 `mode` 默认 `file`,使用文件图标和上传进度;`mode="image"` 按选择顺序展示 104×104 的 `el-image` 方块,不使用 Card,不展示汇总、文件名或状态文案。等待时中央显示空进度条,上传时显示实际进度, 成功或失败后隐藏进度条。右上角提供删除,未上传成功时 - 在原图上叠加灰色遮罩,成功后移除;网络失败和大小超限使用红框,仅网络失败在中央提供重试。 - 失败且有错误信息时通过 MkTooltip 展示文件名及失败原因,其他情况禁用提示。内部仍维护上传状态与 uploadingCount,本地缩略图 URL 在文件移除 + 在原图上叠加灰色遮罩,成功后移除;网络失败和大小超限使用红框,失败时中央显示警告图标, + 在其右侧提供重试图标,两个图标整体居中。仅悬停警告图标且有错误信息时通过 MkTooltip + 展示文件名及失败原因,重试图标单独提示“重试”,两个提示均定位到图片上方,图片其他区域不提示。内部仍维护上传状态与 uploadingCount,本地缩略图 URL 在文件移除 和组件卸载时释放。单文件不使用 mode。 - `accept`、`limit` 控制格式/数量,多文件 `sizeLimit` 默认 100MB;格式、空文件、重复选择移除, - 大小超限保留不可重试 fail 文件,图片模式保留原图和红框;网络失败可重试。 + 大小超限保留 fail 文件,图片模式保留原图和红框;所有失败均提供重试,重试仍执行原有文件校验。 超量发出 exceed,DS_Store 格式不支持时静默移除。 - `dragText`、`selectText`、`tipText`、`replaceText` 覆盖文案;download 作用域插槽提供下载操作。 - 多文件通过 `inject('upload')` 获取 `DragUploadHandler`,返回 `{ request: Promise, abort }`。 @@ -559,6 +560,16 @@ Logo 数据统一从 Theme Store 获取。 - 暴露 clearFiles()、handleRemove(file)、响应式 uploadingCount;公开选择状态 ready/uploading/success/fail。 页面提供具体上传请求与业务校验,不通过展示插槽控制数量限制。 +同目录的 `FileUploadCard.vue` 和 `ImageUploadCard.vue` 可显式导入,供上传列表、对话输入与已发送附件复用。 +两者接收只读 `UploadCardFile` 展示数据,统一呈现进度和失败红框,通过 `retry`、`remove` 事件通知使用方, +不请求接口、不管理文件或预览 URL 生命周期。`percentage` 可覆盖文件内的进度, +失败时均显示重试入口,`showRemove` 控制删除入口,默认显示;`disabled` 统一禁用操作。 +已发送附件传入 success 状态并设置 `showRemove=false`。 +文件卡片通过 `description` 覆盖辅助文案,`name`/`description` 作用域插槽可自定义文字内容; +图片卡片的 `size` 控制缩略图宽高(默认 104px),`iconSize` 控制状态图标大小(默认 16px), +`src` 由使用方提供,`showError` 控制失败警告图标,默认显示。 +图片卡片可通过 `previewSrcList` 启用图片预览,`initialIndex` 指定初始索引(默认 0),预览挂载到 body。 + ### PythonCodeEditor 入口 `codemirror-editor/python.vue`。 diff --git a/ui/src/components/mk-drag-upload/FileUploadCard.vue b/ui/src/components/mk-drag-upload/FileUploadCard.vue new file mode 100644 index 00000000000..29f4f2b2cd4 --- /dev/null +++ b/ui/src/components/mk-drag-upload/FileUploadCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/ui/src/components/mk-drag-upload/ImageUploadCard.vue b/ui/src/components/mk-drag-upload/ImageUploadCard.vue new file mode 100644 index 00000000000..a48fc0a3f8d --- /dev/null +++ b/ui/src/components/mk-drag-upload/ImageUploadCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/ui/src/components/mk-drag-upload/MultipleFileUpload.vue b/ui/src/components/mk-drag-upload/MultipleFileUpload.vue index 3f86ee5c5a9..7c8aadba1f9 100644 --- a/ui/src/components/mk-drag-upload/MultipleFileUpload.vue +++ b/ui/src/components/mk-drag-upload/MultipleFileUpload.vue @@ -4,9 +4,9 @@ import type { UploadFile, UploadFiles, UploadInstance, UploadRawFile, UploadUser import type { DragUploadFile, DragUploadHandler, DragUploadMode } from './types' import FileApi from '@/api/admin/file' import uploadImage from '@/assets/mk_icon_upload.svg' -import { getFileIconUrl } from '@/utils/icon' -import { formatFileSize } from '@/utils/number' import { MsgWarning } from '@/utils/message' +import FileUploadCard from './FileUploadCard.vue' +import ImageUploadCard from './ImageUploadCard.vue' defineOptions({ name: 'MultipleFileUpload' }) @@ -94,7 +94,6 @@ function validateFile(file: DragUploadFile) { file.status = 'fail' file.percentage = 0 file.errMsg = `大小超限` - file.canRetry = false return false } const acceptedTypes = props.accept @@ -127,7 +126,6 @@ function handleUpload(file: DragUploadFile) { file.status = 'uploading' file.percentage = 0 file.errMsg = '' - file.canRetry = false const uid = file.uid // Promise 链同时覆盖上传接口同步抛错和异步请求失败。 void Promise.resolve() @@ -149,7 +147,6 @@ function handleUpload(file: DragUploadFile) { if (file.status !== 'uploading') return file.status = 'fail' file.errMsg = '网络失败' - file.canRetry = true }) .finally(() => uploadRequests.delete(uid)) } @@ -204,17 +201,17 @@ const uploadSummary = computed(() => { summary.completed = summary.success + summary.error return summary }) -const retryFiles = computed(() => fileList.value.filter((file) => file.status === 'fail' && file.canRetry)) +const retryFiles = computed(() => fileList.value.filter((file) => file.status === 'fail')) const sortedFiles = computed(() => [...fileList.value].sort((first, second) => getFileStatusOrder(first) - getFileStatusOrder(second))) function getFileStatusOrder(file: DragUploadFile) { - if (file.status === 'fail') return file.canRetry ? 0 : 1 + if (file.status === 'fail') return 0 if (file.status === 'uploading') return 2 return 3 } function handleRetry(file: DragUploadFile) { - if (props.disabled || file.status !== 'fail' || !file.canRetry) return + if (props.disabled || file.status !== 'fail' || !validateFile(file)) return if (upload) handleUpload(file) else emit('retry', file) } @@ -302,67 +299,18 @@ defineExpose({ clearFiles, handleRemove })
diff --git a/ui/src/components/mk-drag-upload/types.ts b/ui/src/components/mk-drag-upload/types.ts index 74ccb5e8b1d..4acd48ffebf 100644 --- a/ui/src/components/mk-drag-upload/types.ts +++ b/ui/src/components/mk-drag-upload/types.ts @@ -4,9 +4,11 @@ import type { UploadRawFile, UploadUserFile } from 'element-plus' export interface DragUploadFile extends UploadUserFile { file_id?: string errMsg?: string - canRetry?: boolean } +/** 上传卡片的只读展示数据,不依赖具体上传接口。 */ +export type UploadCardFile = Pick + /** 由调用方提供具体上传接口,组件管理进度、完成状态和取消。 */ export type DragUploadHandler = (file: UploadRawFile, onProgress: (percent: number) => void) => { request: Promise; abort: () => void } diff --git a/ui/src/constants/CONSTANT_README.md b/ui/src/constants/CONSTANT_README.md index 41dd156c711..024d39d4ec1 100644 --- a/ui/src/constants/CONSTANT_README.md +++ b/ui/src/constants/CONSTANT_README.md @@ -26,6 +26,10 @@ API 枚举值。例如角色类型和登录方式以 `src/api/enums` 为唯一 `knowledge.ts` 的 `KNOWLEDGE_SEARCH_MODE_OPTIONS` 统一维护向量、全文和混合检索的值、文案及说明, 召回测试页与工作流知识库检索节点直接复用,选项值引用 `KNOWLEDGE_SEARCH_MODE`。 +`knowledge.ts` 的 `KNOWLEDGE_IMAGE_EXTENSIONS` 维护知识库上传和召回测试支持的图片扩展名, +`KNOWLEDGE_IMAGE_ACCEPT` 和 `KNOWLEDGE_IMAGE_TYPE_LABEL` 由它派生,供文件选择、校验与提示使用。 +此配置独立于聊天、工作流等场景使用的通用文件类型配置。 + `schedule.ts` 的 `SCHEDULE_OPTION` 统一提供触发器、长期记忆与知识库定时同步的周期级联选项, 包含周期文案、执行时间和间隔数值;使用方只读,配置回填与校验仍由各自组件维护。 周期类型统一使用 `api/enums/schedule.ts` 的 `SCHEDULE_TYPE`,经 `@/api/enums` 导入。 diff --git a/ui/src/constants/knowledge.ts b/ui/src/constants/knowledge.ts index 20252df2856..56ecd7ce302 100644 --- a/ui/src/constants/knowledge.ts +++ b/ui/src/constants/knowledge.ts @@ -1,6 +1,11 @@ import { KNOWLEDGE_TYPE, KNOWLEDGE_SYNC_TYPE, KNOWLEDGE_SEARCH_MODE } from '@/api/enums' import type { KnowledgeType } from '@/api/types' +/** 知识库图片上传与召回测试共用的格式、文件选择范围和展示文案。 */ +export const KNOWLEDGE_IMAGE_EXTENSIONS: readonly string[] = ['JPG', 'JPEG', 'PNG', 'WEBP', 'BMP'] +export const KNOWLEDGE_IMAGE_ACCEPT = KNOWLEDGE_IMAGE_EXTENSIONS.map((extension) => `.${extension.toLowerCase()}`).join(',') +export const KNOWLEDGE_IMAGE_TYPE_LABEL = KNOWLEDGE_IMAGE_EXTENSIONS.join('、') + /** 前端知识库类型标识。 */ export const KNOWLEDGE_TYPE_KEY = { BASE: 'BASE', diff --git a/ui/src/conversation-panel/components/content/index.vue b/ui/src/conversation-panel/components/content/index.vue index a852e68385d..4bdde4f3934 100644 --- a/ui/src/conversation-panel/components/content/index.vue +++ b/ui/src/conversation-panel/components/content/index.vue @@ -1,9 +1,5 @@ - - + + diff --git a/ui/src/conversation-panel/components/content/items/question.vue b/ui/src/conversation-panel/components/content/items/question.vue index 384f2d4f72a..3492d5d56d0 100644 --- a/ui/src/conversation-panel/components/content/items/question.vue +++ b/ui/src/conversation-panel/components/content/items/question.vue @@ -1,149 +1,85 @@ - - - - + diff --git a/ui/src/conversation-panel/components/content/items/text.vue b/ui/src/conversation-panel/components/content/items/text.vue index 25149af3ee0..4a4862158bc 100644 --- a/ui/src/conversation-panel/components/content/items/text.vue +++ b/ui/src/conversation-panel/components/content/items/text.vue @@ -1,20 +1,17 @@ - - - + diff --git a/ui/src/conversation-panel/components/log-message-list/index.vue b/ui/src/conversation-panel/components/log-message-list/index.vue index 5e571a17ed2..72fa7bdf247 100644 --- a/ui/src/conversation-panel/components/log-message-list/index.vue +++ b/ui/src/conversation-panel/components/log-message-list/index.vue @@ -1,26 +1,3 @@ - - + + + + + diff --git a/ui/src/conversation-panel/components/message-input/index.ts b/ui/src/conversation-panel/components/message-input/index.ts index 1d7a0986896..facff477dc6 100644 --- a/ui/src/conversation-panel/components/message-input/index.ts +++ b/ui/src/conversation-panel/components/message-input/index.ts @@ -11,7 +11,8 @@ export interface FileItem { url?: string file_id?: string previewUrl?: string - uploading?: boolean + status: 'ready' | 'uploading' | 'success' | 'fail' + errMsg?: string /** 上传进度 0-100 */ progress?: number } @@ -66,14 +67,14 @@ export function createMessageInputStore(deps: MessageInputDeps) { return streamLoading.value ? '正在回复中...' : '输入消息...' }) // 附件上传完成前不能发送,否则消息中的附件没有地址 - const isUploading = computed(() => fileList.value.some((attachment) => attachment.uploading)) + const attachmentsReady = computed(() => fileList.value.every((attachment) => attachment.status === 'success')) const canSend = computed( () => !disabled.value && (question.value.trim().length > 0 || fileList.value.length > 0) && !streamLoading.value && !sending.value && - !isUploading.value, + attachmentsReady.value, ) const imageFiles = computed(() => fileList.value.filter((attachment) => isImage(attachment.name))) @@ -82,36 +83,57 @@ export function createMessageInputStore(deps: MessageInputDeps) { const validateFile = (file: File) => fileList.value.length < maxFiles && file.size > 0 && file.size <= maxSizeMB * 1024 * 1024 const addFile = (file: File) => { - if (!validateFile(file)) return + if (disabled.value || streamLoading.value || sending.value || !validateFile(file)) return const attachment: FileItem = reactive({ uid: Date.now() + Math.random(), name: file.name, size: file.size, raw: file, - uploading: true, + status: 'ready', progress: 0, }) if (isImage(file.name) || isAudio(file.name) || isVideo(file.name)) { attachment.previewUrl = URL.createObjectURL(file) } fileList.value.push(attachment) + uploadAttachment(attachment) + } - // 文件发送完后服务端仍在处理,进度停在 95% 直到接口返回 - uploadFile(file, getChatId(), (percent) => { - attachment.progress = Math.min(percent, UPLOAD_PROGRESS_PENDING_MAX) - }) + // 原文件重试复用上传流程;移除或切换会话后忽略旧请求的回调。 + const uploadAttachment = (attachment: FileItem) => { + if (disabled.value || streamLoading.value || sending.value || attachment.status === 'uploading' || !fileList.value.includes(attachment)) return + attachment.status = 'uploading' + attachment.progress = 0 + attachment.errMsg = '' + void Promise.resolve() + .then(() => { + if (!fileList.value.includes(attachment)) return + return uploadFile(attachment.raw, getChatId(), (percent) => { + if (!fileList.value.includes(attachment)) return + // 文件传输结束后仍等待服务端处理,成功响应前最多显示 95%。 + attachment.progress = Number.isFinite(percent) ? Math.min(UPLOAD_PROGRESS_PENDING_MAX, Math.max(0, percent)) : 0 + }) + }) .then((url) => { + if (!fileList.value.includes(attachment)) return + const fileId = url?.split(/[?#]/)[0]?.split('/').filter(Boolean).pop() + if (!url || !fileId) throw new Error('上传未返回文件地址') attachment.url = url - attachment.file_id = url.split('/').pop() + attachment.file_id = fileId + attachment.progress = 100 + attachment.status = 'success' }) - .catch((e) => { - console.error('upload failed:', e) - }) - .finally(() => { - attachment.uploading = false + .catch(() => { + if (!fileList.value.includes(attachment)) return + attachment.status = 'fail' + attachment.errMsg = '上传失败' }) } + const retryFile = (attachment: FileItem) => { + if (attachment.status === 'fail') uploadAttachment(attachment) + } + const addFiles = (files: FileList | File[] | null | undefined) => { if (!files || disabled.value) return Array.from(files).forEach(addFile) @@ -208,6 +230,7 @@ export function createMessageInputStore(deps: MessageInputDeps) { addFile, addFiles, removeFile, + retryFile, send, stop, } diff --git a/ui/src/conversation-panel/components/message-input/index.vue b/ui/src/conversation-panel/components/message-input/index.vue index 8418335f7f0..2d1e8d41e51 100644 --- a/ui/src/conversation-panel/components/message-input/index.vue +++ b/ui/src/conversation-panel/components/message-input/index.vue @@ -1,9 +1,10 @@ diff --git a/ui/src/workflow-canvas/nodes/reranker-node/component/SearchSetting.vue b/ui/src/workflow-canvas/nodes/reranker-node/component/SearchSetting.vue index 2202ba5d96c..d12f743391f 100644 --- a/ui/src/workflow-canvas/nodes/reranker-node/component/SearchSetting.vue +++ b/ui/src/workflow-canvas/nodes/reranker-node/component/SearchSetting.vue @@ -53,15 +53,8 @@ function submit() { align="left" /> - - + + diff --git a/ui/src/workflow-canvas/nodes/reranker-node/index.vue b/ui/src/workflow-canvas/nodes/reranker-node/index.vue index 20bee6c82b1..29a9bd24e93 100644 --- a/ui/src/workflow-canvas/nodes/reranker-node/index.vue +++ b/ui/src/workflow-canvas/nodes/reranker-node/index.vue @@ -73,7 +73,7 @@ function updateModel(patch: Partial) { } const settingRows = computed(() => [ { label: 'Score 高于', value: formData.value.reranker_setting.similarity.toFixed(3) }, - { label: '引用分段数 TOP', value: formData.value.reranker_setting.top_n }, + { label: '召回分段数 TOP', value: formData.value.reranker_setting.top_n }, { label: '最大引用字符数', value: formData.value.reranker_setting.max_paragraph_char_number }, ]) async function validate() { diff --git a/ui/src/workflow-canvas/nodes/search-knowledge-node/component/SearchSetting.vue b/ui/src/workflow-canvas/nodes/search-knowledge-node/component/SearchSetting.vue index 6d51096be3e..5a6e67f5fa0 100644 --- a/ui/src/workflow-canvas/nodes/search-knowledge-node/component/SearchSetting.vue +++ b/ui/src/workflow-canvas/nodes/search-knowledge-node/component/SearchSetting.vue @@ -47,14 +47,20 @@ function resetData() {
{{ option.label }}
- {{ option.description }} + {{ option.description }}
- + + - +
diff --git a/ui/src/workflow-canvas/nodes/search-knowledge-node/index.vue b/ui/src/workflow-canvas/nodes/search-knowledge-node/index.vue index 55186f89bf3..b0d03fe7575 100644 --- a/ui/src/workflow-canvas/nodes/search-knowledge-node/index.vue +++ b/ui/src/workflow-canvas/nodes/search-knowledge-node/index.vue @@ -75,7 +75,7 @@ const settingRows = computed(() => [ formData.value.knowledge_setting.search_mode, }, { label: '相似度高于', value: formData.value.knowledge_setting.similarity.toFixed(3) }, - { label: '引用分段数 TOP', value: formData.value.knowledge_setting.top_n }, + { label: '召回分段数 TOP', value: formData.value.knowledge_setting.top_n }, { label: '最多引用字符数', value: formData.value.knowledge_setting.max_paragraph_char_number }, ])