feat:【antd】【ai】chat “附件列表功能”的迁移

This commit is contained in:
YunaiV
2025-11-15 21:25:14 +08:00
parent 844d4b9b1c
commit a35485e618
7 changed files with 522 additions and 3 deletions

View File

@@ -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,

View File

@@ -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<any>(); // 处理输入中回车的定时器
const prompt = ref<string>(); // prompt
const enableContext = ref<boolean>(true); // 是否开启上下文
const enableWebSearch = ref<boolean>(false); // 是否开启联网搜索
const uploadFiles = ref<string[]>([]); // 上传的文件 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 () => {
></textarea>
<div class="flex justify-between pb-0 pt-1">
<div class="flex items-center gap-3">
<MessageFileUpload
v-model="uploadFiles"
:disabled="conversationInProgress"
/>
<div class="flex items-center">
<Switch v-model:checked="enableContext" size="small" />
<span class="ml-1 text-sm text-gray-400">上下文</span>

View File

@@ -361,8 +361,9 @@ onMounted(async () => {
<Avatar
v-if="conversation.roleAvatar"
:src="conversation.roleAvatar"
:size="28"
/>
<SvgGptIcon v-else class="size-8" />
<SvgGptIcon v-else class="size-6" />
<span
class="max-w-32 overflow-hidden text-ellipsis whitespace-nowrap p-2 text-sm font-normal"
>
@@ -370,6 +371,7 @@ onMounted(async () => {
</span>
</div>
<!-- TODO @AI目前选中的颜色有点丑好像是绿色看看怎么优化下 -->
<div
v-show="hoverConversationId === conversation.id"
class="relative right-0.5 flex items-center text-gray-400"

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
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<HTMLInputElement>();
const fileList = ref<FileItem[]>([]);
const uploadedUrls = ref<string[]>([]);
const showTooltip = ref(false);
const hideTimer = ref<NodeJS.Timeout | null>(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);
}
});
</script>
<template>
<div
v-if="!disabled"
class="relative inline-block"
@mouseenter="showTooltipHandler"
@mouseleave="hideTooltipHandler"
>
<!-- 文件上传按钮 -->
<button
type="button"
class="relative flex h-8 w-8 items-center justify-center rounded-full border-0 bg-transparent text-gray-600 transition-all duration-200 hover:bg-gray-100"
:class="{ 'text-blue-500 hover:bg-blue-50': hasFiles }"
:disabled="isLimitReached"
@click="triggerFileInput"
>
<IconifyIcon icon="lucide:paperclip" :size="16" />
<!-- 文件数量徽章 -->
<span
v-if="hasFiles"
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
>
{{ fileList.length }}
</span>
</button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
multiple
style="display: none"
:accept="acceptTypes"
@change="handleFileSelect"
/>
<!-- Hover 显示的文件列表 -->
<div
v-if="hasFiles && showTooltip"
class="animate-in fade-in slide-in-from-bottom-1 absolute bottom-[calc(100%+8px)] left-1/2 z-[1000] min-w-[240px] max-w-[320px] -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg duration-200"
@mouseenter="showTooltipHandler"
@mouseleave="hideTooltipHandler"
>
<!-- Tooltip 箭头 -->
<div
class="absolute -bottom-[5px] left-1/2 h-0 w-0 -translate-x-1/2 border-l-[5px] border-r-[5px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-200"
>
<div
class="absolute bottom-[1px] left-1/2 h-0 w-0 -translate-x-1/2 border-l-[4px] border-r-[4px] border-t-[4px] border-l-transparent border-r-transparent border-t-white"
></div>
</div>
<!-- 文件列表 -->
<div
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300 hover:scrollbar-thumb-gray-400 scrollbar-thumb-rounded-sm max-h-[200px] overflow-y-auto"
>
<div
v-for="(file, index) in fileList"
:key="index"
class="mb-1 flex items-center justify-between rounded-md bg-gray-50 p-2 text-xs transition-all duration-200 last:mb-0 hover:bg-gray-100"
:class="{ 'opacity-70': file.uploading }"
>
<div class="flex min-w-0 flex-1 items-center">
<IconifyIcon
:icon="getFileIcon(file.name)"
class="mr-2 flex-shrink-0 text-blue-500"
/>
<span
class="mr-1 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-medium text-gray-900"
>
{{ file.name }}
</span>
<span class="flex-shrink-0 text-[11px] text-gray-500">
({{ formatFileSize(file.size) }})
</span>
</div>
<div class="ml-2 flex flex-shrink-0 items-center gap-1">
<div
v-if="file.uploading"
class="h-1 w-[60px] overflow-hidden rounded-full bg-gray-200"
>
<div
class="h-full bg-blue-500 transition-all duration-300"
:style="{ width: `${file.progress || 0}%` }"
></div>
</div>
<button
v-else-if="!disabled"
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
@click="removeFile(index)"
>
<IconifyIcon icon="lucide:x" :size="12" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getFileIcon, getFileNameFromUrl, getFileTypeClass } from '@vben/utils';
const props = defineProps<{
attachmentUrls?: string[];
}>();
/** 过滤掉空值的附件列表 */
const validAttachmentUrls = computed(() => {
return (props.attachmentUrls || []).filter((url) => url && url.trim());
});
/** 点击文件 */
function handleFileClick(url: string) {
window.open(url, '_blank');
}
</script>
<template>
<div v-if="validAttachmentUrls.length > 0" class="mt-2">
<div class="flex flex-wrap gap-2">
<div
v-for="(url, index) in validAttachmentUrls"
:key="index"
class="max-w-70 flex min-w-40 cursor-pointer items-center rounded-lg border border-transparent bg-gray-100 p-3 transition-all duration-200 hover:-translate-y-1 hover:bg-gray-200 hover:shadow-lg"
@click="handleFileClick(url)"
>
<div class="mr-3 flex-shrink-0">
<div
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br font-bold text-white"
:class="getFileTypeClass(getFileNameFromUrl(url))"
>
<IconifyIcon
:icon="getFileIcon(getFileNameFromUrl(url))"
:size="20"
/>
</div>
</div>
<div class="min-w-0 flex-1">
<div
class="mb-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-tight text-gray-800"
:title="getFileNameFromUrl(url)"
>
{{ getFileNameFromUrl(url) }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -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"
/>
<MessageFiles :attachment-urls="item.attachmentUrls" />
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
<MessageWebSearch
v-if="item.webSearchPages"
@@ -176,8 +178,15 @@ onMounted(async () => {
<div class="text-left leading-8">
{{ formatDate(item.createTime) }}
</div>
<div
v-if="item.attachmentUrls && item.attachmentUrls.length > 0"
class="mb-2 flex flex-row-reverse"
>
<MessageFiles :attachment-urls="item.attachmentUrls" />
</div>
<div class="flex flex-row-reverse">
<div
v-if="item.content && item.content.trim()"
class="inline w-auto whitespace-pre-wrap break-words rounded-lg bg-blue-500 p-2.5 text-sm text-white shadow-sm"
>
{{ item.content }}

View File

@@ -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';
}