fix: 网页版增加对话文件支持
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user