feat:【antd】【ai】chat “附件列表功能”的迁移
This commit is contained in:
@@ -29,6 +29,7 @@ export namespace AiChatMessageApi {
|
|||||||
id: number; // 段落编号
|
id: number; // 段落编号
|
||||||
}[];
|
}[];
|
||||||
webSearchPages?: WebSearchPage[]; // 联网搜索结果
|
webSearchPages?: WebSearchPage[]; // 联网搜索结果
|
||||||
|
attachmentUrls?: string[]; // 附件 URL 数组
|
||||||
createTime: Date; // 创建时间
|
createTime: Date; // 创建时间
|
||||||
roleAvatar: string; // 角色头像
|
roleAvatar: string; // 角色头像
|
||||||
userAvatar: string; // 用户头像
|
userAvatar: string; // 用户头像
|
||||||
@@ -64,6 +65,7 @@ export function sendChatMessageStream(
|
|||||||
onMessage: any,
|
onMessage: any,
|
||||||
onError: any,
|
onError: any,
|
||||||
onClose: any,
|
onClose: any,
|
||||||
|
attachmentUrls?: string[],
|
||||||
) {
|
) {
|
||||||
const token = accessStore.accessToken;
|
const token = accessStore.accessToken;
|
||||||
return fetchEventSource(`${apiURL}/ai/chat/message/send-stream`, {
|
return fetchEventSource(`${apiURL}/ai/chat/message/send-stream`, {
|
||||||
@@ -78,6 +80,7 @@ export function sendChatMessageStream(
|
|||||||
content,
|
content,
|
||||||
useContext: enableContext,
|
useContext: enableContext,
|
||||||
useSearch: enableWebSearch,
|
useSearch: enableWebSearch,
|
||||||
|
attachmentUrls: attachmentUrls || [],
|
||||||
}),
|
}),
|
||||||
onmessage: onMessage,
|
onmessage: onMessage,
|
||||||
onerror: onError,
|
onerror: onError,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
|
|
||||||
import ConversationList from './modules/conversation/list.vue';
|
import ConversationList from './modules/conversation/list.vue';
|
||||||
import ConversationUpdateForm from './modules/conversation/update-form.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 MessageListEmpty from './modules/message/list-empty.vue';
|
||||||
import MessageList from './modules/message/list.vue';
|
import MessageList from './modules/message/list.vue';
|
||||||
import MessageLoading from './modules/message/loading.vue';
|
import MessageLoading from './modules/message/loading.vue';
|
||||||
@@ -58,6 +59,7 @@ const inputTimeout = ref<any>(); // 处理输入中回车的定时器
|
|||||||
const prompt = ref<string>(); // prompt
|
const prompt = ref<string>(); // prompt
|
||||||
const enableContext = ref<boolean>(true); // 是否开启上下文
|
const enableContext = ref<boolean>(true); // 是否开启上下文
|
||||||
const enableWebSearch = ref<boolean>(false); // 是否开启联网搜索
|
const enableWebSearch = ref<boolean>(false); // 是否开启联网搜索
|
||||||
|
const uploadFiles = ref<string[]>([]); // 上传的文件 URL 列表
|
||||||
// 接收 Stream 消息
|
// 接收 Stream 消息
|
||||||
const receiveMessageFullText = ref('');
|
const receiveMessageFullText = ref('');
|
||||||
const receiveMessageDisplayedText = ref('');
|
const receiveMessageDisplayedText = ref('');
|
||||||
@@ -101,8 +103,11 @@ async function handleConversationClick(
|
|||||||
// 滚动底部
|
// 滚动底部
|
||||||
// TODO @AI:看看要不要 await
|
// TODO @AI:看看要不要 await
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
|
prompt.value = '';
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
prompt.value = '';
|
prompt.value = '';
|
||||||
|
// 清空文件列表
|
||||||
|
uploadFiles.value = [];
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +131,9 @@ async function handleConversationClear() {
|
|||||||
activeConversationId.value = null;
|
activeConversationId.value = null;
|
||||||
activeConversation.value = null;
|
activeConversation.value = null;
|
||||||
activeMessageList.value = [];
|
activeMessageList.value = [];
|
||||||
|
// 清空输入框和文件列表
|
||||||
|
prompt.value = '';
|
||||||
|
uploadFiles.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChatConversationUpdateForm() {
|
async function openChatConversationUpdateForm() {
|
||||||
@@ -147,6 +155,8 @@ async function handleConversationCreate() {
|
|||||||
async function handleConversationCreateSuccess() {
|
async function handleConversationCreateSuccess() {
|
||||||
// 创建新的对话,清空输入框
|
// 创建新的对话,清空输入框
|
||||||
prompt.value = '';
|
prompt.value = '';
|
||||||
|
// 清空文件列表
|
||||||
|
uploadFiles.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========== 【消息列表】相关 ===========
|
// =========== 【消息列表】相关 ===========
|
||||||
@@ -304,12 +314,19 @@ async function doSendMessage(content: string) {
|
|||||||
message.error('还没创建对话,不能发送!');
|
message.error('还没创建对话,不能发送!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 清空输入框
|
|
||||||
|
// 准备附件 URL 数组
|
||||||
|
const attachmentUrls = [...uploadFiles.value];
|
||||||
|
|
||||||
|
// 清空输入框和文件列表
|
||||||
prompt.value = '';
|
prompt.value = '';
|
||||||
|
uploadFiles.value = [];
|
||||||
|
|
||||||
// 执行发送
|
// 执行发送
|
||||||
await doSendMessageStream({
|
await doSendMessageStream({
|
||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
content,
|
content,
|
||||||
|
attachmentUrls,
|
||||||
} as AiChatMessageApi.ChatMessage);
|
} as AiChatMessageApi.ChatMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +347,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
|||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: userMessage.content,
|
content: userMessage.content,
|
||||||
|
attachmentUrls: userMessage.attachmentUrls || [],
|
||||||
createTime: new Date(),
|
createTime: new Date(),
|
||||||
} as AiChatMessageApi.ChatMessage,
|
} as AiChatMessageApi.ChatMessage,
|
||||||
{
|
{
|
||||||
@@ -337,7 +355,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
|||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: '思考中...',
|
content: '思考中...',
|
||||||
reasoningContent: '', // 初始化推理内容
|
reasoningContent: '',
|
||||||
createTime: new Date(),
|
createTime: new Date(),
|
||||||
} as AiChatMessageApi.ChatMessage,
|
} as AiChatMessageApi.ChatMessage,
|
||||||
);
|
);
|
||||||
@@ -380,6 +398,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
|||||||
activeMessageList.value.pop();
|
activeMessageList.value.pop();
|
||||||
// 更新返回的数据
|
// 更新返回的数据
|
||||||
activeMessageList.value.push(data.send, data.receive);
|
activeMessageList.value.push(data.send, data.receive);
|
||||||
|
data.send.attachmentUrls = userMessage.attachmentUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 reasoningContent
|
// 处理 reasoningContent
|
||||||
@@ -411,6 +430,7 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
|||||||
() => {
|
() => {
|
||||||
stopStream();
|
stopStream();
|
||||||
},
|
},
|
||||||
|
userMessage.attachmentUrls,
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -614,6 +634,10 @@ onMounted(async () => {
|
|||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-between pb-0 pt-1">
|
<div class="flex justify-between pb-0 pt-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<MessageFileUpload
|
||||||
|
v-model="uploadFiles"
|
||||||
|
:disabled="conversationInProgress"
|
||||||
|
/>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Switch v-model:checked="enableContext" size="small" />
|
<Switch v-model:checked="enableContext" size="small" />
|
||||||
<span class="ml-1 text-sm text-gray-400">上下文</span>
|
<span class="ml-1 text-sm text-gray-400">上下文</span>
|
||||||
|
|||||||
@@ -361,8 +361,9 @@ onMounted(async () => {
|
|||||||
<Avatar
|
<Avatar
|
||||||
v-if="conversation.roleAvatar"
|
v-if="conversation.roleAvatar"
|
||||||
:src="conversation.roleAvatar"
|
:src="conversation.roleAvatar"
|
||||||
|
:size="28"
|
||||||
/>
|
/>
|
||||||
<SvgGptIcon v-else class="size-8" />
|
<SvgGptIcon v-else class="size-6" />
|
||||||
<span
|
<span
|
||||||
class="max-w-32 overflow-hidden text-ellipsis whitespace-nowrap p-2 text-sm font-normal"
|
class="max-w-32 overflow-hidden text-ellipsis whitespace-nowrap p-2 text-sm font-normal"
|
||||||
>
|
>
|
||||||
@@ -370,6 +371,7 @@ onMounted(async () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO @AI:目前选中的颜色有点丑,好像是绿色,看看怎么优化下! -->
|
||||||
<div
|
<div
|
||||||
v-show="hoverConversationId === conversation.id"
|
v-show="hoverConversationId === conversation.id"
|
||||||
class="relative right-0.5 flex items-center text-gray-400"
|
class="relative right-0.5 flex items-center text-gray-400"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -17,6 +17,7 @@ import { Avatar, Button, message } from 'ant-design-vue';
|
|||||||
import { deleteChatMessage } from '#/api/ai/chat/message';
|
import { deleteChatMessage } from '#/api/ai/chat/message';
|
||||||
import { MarkdownView } from '#/components/markdown-view';
|
import { MarkdownView } from '#/components/markdown-view';
|
||||||
|
|
||||||
|
import MessageFiles from './files.vue';
|
||||||
import MessageKnowledge from './knowledge.vue';
|
import MessageKnowledge from './knowledge.vue';
|
||||||
import MessageReasoning from './reasoning.vue';
|
import MessageReasoning from './reasoning.vue';
|
||||||
import MessageWebSearch from './web-search.vue';
|
import MessageWebSearch from './web-search.vue';
|
||||||
@@ -141,6 +142,7 @@ onMounted(async () => {
|
|||||||
class="text-sm text-gray-600"
|
class="text-sm text-gray-600"
|
||||||
:content="item.content"
|
:content="item.content"
|
||||||
/>
|
/>
|
||||||
|
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
||||||
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
||||||
<MessageWebSearch
|
<MessageWebSearch
|
||||||
v-if="item.webSearchPages"
|
v-if="item.webSearchPages"
|
||||||
@@ -176,8 +178,15 @@ onMounted(async () => {
|
|||||||
<div class="text-left leading-8">
|
<div class="text-left leading-8">
|
||||||
{{ formatDate(item.createTime) }}
|
{{ formatDate(item.createTime) }}
|
||||||
</div>
|
</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 class="flex flex-row-reverse">
|
||||||
<div
|
<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"
|
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 }}
|
{{ item.content }}
|
||||||
|
|||||||
@@ -65,3 +65,127 @@ export function generateAcceptedFileTypes(
|
|||||||
|
|
||||||
return [...mimeTypes, ...extensions].join(',');
|
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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user