fix: 网页版增加对话文件支持

This commit is contained in:
Gsh
2025-12-13 23:08:11 +08:00
parent c073868989
commit 944626960b
7 changed files with 1022 additions and 136 deletions

View File

@@ -5,7 +5,7 @@ import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
import { ElIcon, ElMessage } from 'element-plus';
import { useHookFetch } from 'hook-fetch/vue';
import { computed, nextTick, ref, watch } from 'vue';
@@ -30,6 +30,7 @@ type MessageItem = BubbleProps & {
thinlCollapse?: boolean;
reasoning_content?: string;
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
};
const route = useRoute();
@@ -114,7 +115,11 @@ watch(
{ immediate: true, deep: true },
);
// 封装数据处理逻辑
/**
* 处理流式响应的数据块
* 解析 AI 返回的数据,更新消息内容和思考状态
* @param {AnyObject} chunk - 流式响应的数据块
*/
function handleDataChunk(chunk: AnyObject) {
try {
// 安全获取 delta 和 content
@@ -170,11 +175,19 @@ function handleDataChunk(chunk: AnyObject) {
}
}
// 封装错误处理逻辑
/**
* 处理错误信息
* @param {any} err - 错误对象
*/
function handleError(err: any) {
console.error('Fetch error:', err);
}
/**
* 发送消息并处理流式响应
* 支持发送文本、图片和文件
* @param {string} chatContent - 用户输入的文本内容
*/
async function startSSE(chatContent: string) {
if (isSending.value)
return;
@@ -192,14 +205,21 @@ async function startSSE(chatContent: string) {
// 清空输入框
inputValue.value = '';
// 获取当前上传的图片(在清空之前保存)
const imageFiles = filesStore.filesList.filter(f => f.isUploaded && f.file.type.startsWith('image/'));
// 获取当前上传的图片和文件(在清空之前保存)
const imageFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'image');
const textFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'text');
const images = imageFiles.map(f => ({
url: f.base64!, // 使用base64作为URL
name: f.name,
}));
addMessage(chatContent, true, images);
const files = textFiles.map(f => ({
name: f.name!,
size: f.fileSize!,
}));
addMessage(chatContent, true, images, files);
addMessage('', false);
// 立即清空文件列表(不要等到响应完成)
@@ -208,13 +228,13 @@ async function startSSE(chatContent: string) {
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
bubbleListRef.value?.scrollToBottom();
// 组装消息内容,支持图片
// 组装消息内容,支持图片和文件
const messagesContent = bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => {
const baseMessage: any = {
role: item.role,
};
// 如果是用户消息且有附件(图片),组装成数组格式
// 如果是用户消息且有附件(图片或文件),组装成数组格式
if (item.role === 'user' && item.key === bubbleItems.value.length - 2) {
// 当前发送的消息
const contentArray: any[] = [];
@@ -227,6 +247,26 @@ async function startSSE(chatContent: string) {
});
}
// 添加文本文件内容使用XML格式
if (textFiles.length > 0) {
let fileContent = '\n\n';
textFiles.forEach((fileItem, index) => {
fileContent += `<ATTACHMENT_FILE>\n`;
fileContent += `<FILE_INDEX>File ${index + 1}</FILE_INDEX>\n`;
fileContent += `<FILE_NAME>${fileItem.name}</FILE_NAME>\n`;
fileContent += `<FILE_CONTENT>\n${fileItem.fileContent}\n</FILE_CONTENT>\n`;
fileContent += `</ATTACHMENT_FILE>\n`;
if (index < textFiles.length - 1) {
fileContent += '\n';
}
});
contentArray.push({
type: 'text',
text: fileContent,
});
}
// 添加图片内容(使用之前保存的 imageFiles
imageFiles.forEach((fileItem) => {
if (fileItem.base64) {
@@ -240,7 +280,7 @@ async function startSSE(chatContent: string) {
});
// 如果有图片或文件,使用数组格式
if (contentArray.length > 1 || imageFiles.length > 0) {
if (contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0) {
baseMessage.content = contentArray;
} else {
baseMessage.content = item.content;
@@ -287,10 +327,18 @@ async function startSSE(chatContent: string) {
latest.thinkingStatus = 'end';
}
}
// 保存聊天记录到 chatMap本地缓存刷新后可恢复
if (route.params?.id && route.params.id !== 'not_login') {
chatStore.chatMap[`${route.params.id}`] = bubbleItems.value as any;
}
}
}
// 中断请求
/**
* 中断正在进行的请求
* 停止流式响应并重置状态
*/
async function cancelSSE() {
try {
cancel(); // 直接调用,无需参数
@@ -309,8 +357,14 @@ async function cancelSSE() {
}
}
// 添加消息 - 维护聊天记录
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>) {
/**
* 添加消息到聊天列表
* @param {string} message - 消息内容
* @param {boolean} isUser - 是否为用户消息
* @param {Array<{url: string, name?: string}>} images - 图片列表(可选)
* @param {Array<{name: string, size: number}>} files - 文件列表(可选)
*/
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>, files?: Array<{ name: string; size: number }>) {
const i = bubbleItems.value.length;
const obj: MessageItem = {
key: i,
@@ -328,14 +382,25 @@ function addMessage(message: string, isUser: boolean, images?: Array<{ url: stri
thinlCollapse: false,
noStyle: !isUser,
images: images && images.length > 0 ? images : undefined,
files: files && files.length > 0 ? files : undefined,
};
bubbleItems.value.push(obj);
}
// 展开收起 事件展示
/**
* 处理思考链展开/收起状态变化
* @param {Object} payload - 状态变化的载荷
* @param {boolean} payload.value - 展开/收起状态
* @param {ThinkingStatus} payload.status - 思考状态
*/
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
}
/**
* 删除文件卡片
* @param {FilesCardProps} _item - 文件卡片项(未使用)
* @param {number} index - 要删除的文件索引
*/
function handleDeleteCard(_item: FilesCardProps, index: number) {
filesStore.deleteFileByIndex(index);
}
@@ -356,14 +421,21 @@ watch(
},
);
// 复制
/**
* 复制消息内容到剪贴板
* @param {any} item - 消息项
*/
function copy(item: any) {
navigator.clipboard.writeText(item.content || '')
.then(() => ElMessage.success('已复制到剪贴板'))
.catch(() => ElMessage.error('复制失败'));
}
// 图片预览
/**
* 图片预览
* 在新窗口中打开图片
* @param {string} url - 图片 URL
*/
function handleImagePreview(url: string) {
window.open(url, '_blank');
}
@@ -383,7 +455,7 @@ function handleImagePreview(url: string) {
<template #content="{ item }">
<!-- chat 内容走 markdown -->
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
<!-- user 内容 纯文本 + 图片 -->
<!-- user 内容 纯文本 + 图片 + 文件 -->
<div v-if="item.role === 'user'" class="user-content-wrapper">
<!-- 图片列表 -->
<div v-if="item.images && item.images.length > 0" class="user-images">
@@ -396,6 +468,20 @@ function handleImagePreview(url: string) {
@click="() => handleImagePreview(image.url)"
>
</div>
<!-- 文件列表 -->
<div v-if="item.files && item.files.length > 0" class="user-files">
<div
v-for="(file, index) in item.files"
:key="index"
class="user-file-item"
>
<el-icon class="file-icon">
<Document />
</el-icon>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ (file.size / 1024).toFixed(2) }} KB</span>
</div>
</div>
<!-- 文本内容 -->
<div v-if="item.content" class="user-content">
{{ item.content }}
@@ -523,6 +609,35 @@ function handleImagePreview(url: string) {
transform: scale(1.05);
}
}
.user-files {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 4px;
}
.user-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 6px;
font-size: 13px;
.file-icon {
font-size: 16px;
color: #409eff;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #909399;
font-size: 12px;
}
}
.user-content {
// 换行
white-space: pre-wrap;