From a35485e618f4cf70c7c266cba679d799e69aef48 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 15 Nov 2025 21:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E3=80=90antd=E3=80=91=E3=80=90ai?= =?UTF-8?q?=E3=80=91chat=20=E2=80=9C=E9=99=84=E4=BB=B6=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E2=80=9D=E7=9A=84=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web-antd/src/api/ai/chat/message/index.ts | 3 + .../src/views/ai/chat/index/index.vue | 28 +- .../chat/index/modules/conversation/list.vue | 4 +- .../index/modules/message/file-upload.vue | 304 ++++++++++++++++++ .../ai/chat/index/modules/message/files.vue | 53 +++ .../ai/chat/index/modules/message/list.vue | 9 + .../@core/base/shared/src/utils/upload.ts | 124 +++++++ 7 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 apps/web-antd/src/views/ai/chat/index/modules/message/file-upload.vue create mode 100644 apps/web-antd/src/views/ai/chat/index/modules/message/files.vue diff --git a/apps/web-antd/src/api/ai/chat/message/index.ts b/apps/web-antd/src/api/ai/chat/message/index.ts index 4c65446e6..d2677ad55 100644 --- a/apps/web-antd/src/api/ai/chat/message/index.ts +++ b/apps/web-antd/src/api/ai/chat/message/index.ts @@ -29,6 +29,7 @@ export namespace AiChatMessageApi { id: number; // 段落编号 }[]; webSearchPages?: WebSearchPage[]; // 联网搜索结果 + attachmentUrls?: string[]; // 附件 URL 数组 createTime: Date; // 创建时间 roleAvatar: string; // 角色头像 userAvatar: string; // 用户头像 @@ -64,6 +65,7 @@ export function sendChatMessageStream( onMessage: any, onError: any, onClose: any, + attachmentUrls?: string[], ) { const token = accessStore.accessToken; return fetchEventSource(`${apiURL}/ai/chat/message/send-stream`, { @@ -78,6 +80,7 @@ export function sendChatMessageStream( content, useContext: enableContext, useSearch: enableWebSearch, + attachmentUrls: attachmentUrls || [], }), onmessage: onMessage, onerror: onError, diff --git a/apps/web-antd/src/views/ai/chat/index/index.vue b/apps/web-antd/src/views/ai/chat/index/index.vue index 931778f93..0bbb95be0 100644 --- a/apps/web-antd/src/views/ai/chat/index/index.vue +++ b/apps/web-antd/src/views/ai/chat/index/index.vue @@ -20,6 +20,7 @@ import { import ConversationList from './modules/conversation/list.vue'; import ConversationUpdateForm from './modules/conversation/update-form.vue'; +import MessageFileUpload from './modules/message/file-upload.vue'; import MessageListEmpty from './modules/message/list-empty.vue'; import MessageList from './modules/message/list.vue'; import MessageLoading from './modules/message/loading.vue'; @@ -58,6 +59,7 @@ const inputTimeout = ref(); // 处理输入中回车的定时器 const prompt = ref(); // prompt const enableContext = ref(true); // 是否开启上下文 const enableWebSearch = ref(false); // 是否开启联网搜索 +const uploadFiles = ref([]); // 上传的文件 URL 列表 // 接收 Stream 消息 const receiveMessageFullText = ref(''); const receiveMessageDisplayedText = ref(''); @@ -101,8 +103,11 @@ async function handleConversationClick( // 滚动底部 // TODO @AI:看看要不要 await scrollToBottom(true); + prompt.value = ''; // 清空输入框 prompt.value = ''; + // 清空文件列表 + uploadFiles.value = []; return true; } @@ -126,6 +131,9 @@ async function handleConversationClear() { activeConversationId.value = null; activeConversation.value = null; activeMessageList.value = []; + // 清空输入框和文件列表 + prompt.value = ''; + uploadFiles.value = []; } async function openChatConversationUpdateForm() { @@ -147,6 +155,8 @@ async function handleConversationCreate() { async function handleConversationCreateSuccess() { // 创建新的对话,清空输入框 prompt.value = ''; + // 清空文件列表 + uploadFiles.value = [] } // =========== 【消息列表】相关 =========== @@ -304,12 +314,19 @@ async function doSendMessage(content: string) { message.error('还没创建对话,不能发送!'); return; } - // 清空输入框 + + // 准备附件 URL 数组 + const attachmentUrls = [...uploadFiles.value]; + + // 清空输入框和文件列表 prompt.value = ''; + uploadFiles.value = []; + // 执行发送 await doSendMessageStream({ conversationId: activeConversationId.value, content, + attachmentUrls, } as AiChatMessageApi.ChatMessage); } @@ -330,6 +347,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) { conversationId: activeConversationId.value, type: 'user', content: userMessage.content, + attachmentUrls: userMessage.attachmentUrls || [], createTime: new Date(), } as AiChatMessageApi.ChatMessage, { @@ -337,7 +355,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) { conversationId: activeConversationId.value, type: 'assistant', content: '思考中...', - reasoningContent: '', // 初始化推理内容 + reasoningContent: '', createTime: new Date(), } as AiChatMessageApi.ChatMessage, ); @@ -380,6 +398,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) { activeMessageList.value.pop(); // 更新返回的数据 activeMessageList.value.push(data.send, data.receive); + data.send.attachmentUrls = userMessage.attachmentUrls; } // 处理 reasoningContent @@ -411,6 +430,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) { () => { stopStream(); }, + userMessage.attachmentUrls, ); } catch {} } @@ -614,6 +634,10 @@ onMounted(async () => { >
+
上下文 diff --git a/apps/web-antd/src/views/ai/chat/index/modules/conversation/list.vue b/apps/web-antd/src/views/ai/chat/index/modules/conversation/list.vue index a52ed6291..37eb11d72 100644 --- a/apps/web-antd/src/views/ai/chat/index/modules/conversation/list.vue +++ b/apps/web-antd/src/views/ai/chat/index/modules/conversation/list.vue @@ -361,8 +361,9 @@ onMounted(async () => { - + @@ -370,6 +371,7 @@ onMounted(async () => {
+
+import { computed, onUnmounted, ref, watch } from 'vue'; + +import { IconifyIcon } from '@vben/icons'; +import { formatFileSize, getFileIcon } from '@vben/utils'; + +import { message } from 'ant-design-vue'; + +import { useUpload } from '#/components/upload/use-upload'; + +export interface FileItem { + name: string; + size: number; + url?: string; + uploading?: boolean; + progress?: number; + raw?: File; +} + +const props = withDefaults( + defineProps<{ + acceptTypes?: string; + disabled?: boolean; + limit?: number; + maxSize?: number; + modelValue?: string[]; + }>(), + { + modelValue: () => [], + limit: 5, + maxSize: 10, + acceptTypes: + '.jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.csv,.md', + disabled: false, + }, +); + +const emit = defineEmits<{ + 'update:modelValue': [value: string[]]; + uploadError: [error: any]; + uploadSuccess: [file: FileItem]; +}>(); + +const fileInputRef = ref(); +const fileList = ref([]); +const uploadedUrls = ref([]); +const showTooltip = ref(false); +const hideTimer = ref(null); +const { httpRequest } = useUpload(); + +/** 监听 v-model 变化 */ +watch( + () => props.modelValue, + (newVal) => { + uploadedUrls.value = [...newVal]; + if (newVal.length === 0) { + fileList.value = []; + } + }, + { immediate: true, deep: true }, +); + +/** 是否有文件 */ +const hasFiles = computed(() => fileList.value.length > 0); + +/** 是否达到上传限制 */ +const isLimitReached = computed(() => fileList.value.length >= props.limit); + +/** 触发文件选择 */ +function triggerFileInput() { + fileInputRef.value?.click(); +} + +/** 显示 tooltip */ +function showTooltipHandler() { + if (hideTimer.value) { + clearTimeout(hideTimer.value); + hideTimer.value = null; + } + showTooltip.value = true; +} + +/** 隐藏 tooltip */ +function hideTooltipHandler() { + hideTimer.value = setTimeout(() => { + showTooltip.value = false; + hideTimer.value = null; + }, 300); +} + +/** 处理文件选择 */ +async function handleFileSelect(event: Event) { + const target = event.target as HTMLInputElement; + const files = [...(target.files || [])]; + if (files.length === 0) { + return; + } + + if (files.length + fileList.value.length > props.limit) { + message.error(`最多只能上传 ${props.limit} 个文件`); + target.value = ''; + return; + } + + for (const file of files) { + if (file.size > props.maxSize * 1024 * 1024) { + message.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`); + continue; + } + + const fileItem: FileItem = { + name: file.name, + size: file.size, + uploading: true, + progress: 0, + raw: file, + }; + fileList.value.push(fileItem); + await uploadFile(fileItem); + } + + target.value = ''; +} + +/** 上传文件 */ +async function uploadFile(fileItem: FileItem) { + try { + const progressInterval = setInterval(() => { + if (fileItem.progress! < 90) { + fileItem.progress = (fileItem.progress || 0) + Math.random() * 10; + } + }, 100); + + const response = await httpRequest(fileItem.raw!); + clearInterval(progressInterval); + + fileItem.uploading = false; + fileItem.progress = 100; + + // 调试日志 + console.log('上传响应:', response); + + // 兼容不同的返回格式:{ url: '...' } 或 { data: '...' } 或直接是字符串 + const fileUrl = + (response as any)?.url || (response as any)?.data || response; + fileItem.url = fileUrl; + + console.log('提取的文件 URL:', fileUrl); + + // 只有当 URL 有效时才添加到列表 + if (fileUrl && typeof fileUrl === 'string') { + uploadedUrls.value.push(fileUrl); + emit('uploadSuccess', fileItem); + updateModelValue(); + } else { + throw new Error('上传返回的 URL 无效'); + } + } catch (error) { + fileItem.uploading = false; + message.error(`文件 ${fileItem.name} 上传失败`); + emit('uploadError', error); + + const index = fileList.value.indexOf(fileItem); + if (index !== -1) { + removeFile(index); + } + } +} + +/** 删除文件 */ +function removeFile(index: number) { + const removedFile = fileList.value[index]; + fileList.value.splice(index, 1); + if (removedFile?.url) { + const urlIndex = uploadedUrls.value.indexOf(removedFile.url); + if (urlIndex !== -1) { + uploadedUrls.value.splice(urlIndex, 1); + } + } + updateModelValue(); +} + +/** 更新 v-model */ +function updateModelValue() { + emit('update:modelValue', [...uploadedUrls.value]); +} + +/** 清空文件 */ +function clearFiles() { + fileList.value = []; + uploadedUrls.value = []; + updateModelValue(); +} + +defineExpose({ + triggerFileInput, + clearFiles, +}); + +onUnmounted(() => { + if (hideTimer.value) { + clearTimeout(hideTimer.value); + } +}); + + + diff --git a/apps/web-antd/src/views/ai/chat/index/modules/message/files.vue b/apps/web-antd/src/views/ai/chat/index/modules/message/files.vue new file mode 100644 index 000000000..ed163a026 --- /dev/null +++ b/apps/web-antd/src/views/ai/chat/index/modules/message/files.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/web-antd/src/views/ai/chat/index/modules/message/list.vue b/apps/web-antd/src/views/ai/chat/index/modules/message/list.vue index 11499338a..98b546dd8 100644 --- a/apps/web-antd/src/views/ai/chat/index/modules/message/list.vue +++ b/apps/web-antd/src/views/ai/chat/index/modules/message/list.vue @@ -17,6 +17,7 @@ import { Avatar, Button, message } from 'ant-design-vue'; import { deleteChatMessage } from '#/api/ai/chat/message'; import { MarkdownView } from '#/components/markdown-view'; +import MessageFiles from './files.vue'; import MessageKnowledge from './knowledge.vue'; import MessageReasoning from './reasoning.vue'; import MessageWebSearch from './web-search.vue'; @@ -141,6 +142,7 @@ onMounted(async () => { class="text-sm text-gray-600" :content="item.content" /> + {
{{ formatDate(item.createTime) }}
+
+ +
{{ item.content }} diff --git a/packages/@core/base/shared/src/utils/upload.ts b/packages/@core/base/shared/src/utils/upload.ts index 3a349ed69..04f6421ff 100644 --- a/packages/@core/base/shared/src/utils/upload.ts +++ b/packages/@core/base/shared/src/utils/upload.ts @@ -65,3 +65,127 @@ export function generateAcceptedFileTypes( return [...mimeTypes, ...extensions].join(','); } + +/** + * 从 URL 中提取文件名 + * + * @param url 文件 URL + * @returns 文件名,如果无法提取则返回 'unknown' + */ +export function getFileNameFromUrl(url: null | string | undefined): string { + // 处理空值 + if (!url) { + return 'unknown'; + } + + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const fileName = pathname.split('/').pop() || 'unknown'; + return decodeURIComponent(fileName); + } catch { + // 如果 URL 解析失败,尝试从字符串中提取 + const parts = url.split('/'); + return parts[parts.length - 1] || 'unknown'; + } +} + +/** + * 判断文件是否为图片 + * + * @param filename 文件名 + * @returns 是否为图片 + */ +export function isImage(filename: null | string | undefined): boolean { + if (!filename) { + return false; + } + const ext = filename.split('.').pop()?.toLowerCase() || ''; + return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext); +} + +/** + * 格式化文件大小 + * + * @param bytes 文件大小(字节) + * @returns 格式化后的文件大小字符串 + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +} + +/** + * 获取文件图标(Lucide Icons) + * + * @param filename 文件名 + * @returns Lucide 图标名称 + */ +export function getFileIcon(filename: null | string | undefined): string { + if (!filename) { + return 'lucide:file'; + } + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (isImage(ext)) { + return 'lucide:image'; + } + if (['pdf'].includes(ext)) { + return 'lucide:file-text'; + } + if (['doc', 'docx'].includes(ext)) { + return 'lucide:file-text'; + } + if (['xls', 'xlsx'].includes(ext)) { + return 'lucide:file-spreadsheet'; + } + if (['ppt', 'pptx'].includes(ext)) { + return 'lucide:presentation'; + } + if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) { + return 'lucide:music'; + } + if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) { + return 'lucide:video'; + } + return 'lucide:file'; +} + +/** + * 获取文件类型样式类(Tailwind CSS 渐变色) + * + * @param filename 文件名 + * @returns Tailwind CSS 渐变类名 + */ +export function getFileTypeClass(filename: null | string | undefined): string { + if (!filename) { + return 'from-gray-500 to-gray-700'; + } + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (isImage(ext)) { + return 'from-yellow-400 to-orange-500'; + } + if (['pdf'].includes(ext)) { + return 'from-red-500 to-red-700'; + } + if (['doc', 'docx'].includes(ext)) { + return 'from-blue-600 to-blue-800'; + } + if (['xls', 'xlsx'].includes(ext)) { + return 'from-green-600 to-green-800'; + } + if (['ppt', 'pptx'].includes(ext)) { + return 'from-orange-600 to-orange-800'; + } + if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) { + return 'from-purple-500 to-purple-700'; + } + if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) { + return 'from-red-500 to-red-700'; + } + return 'from-gray-500 to-gray-700'; +}