fix: 网页版增加对话文件支持
This commit is contained in:
@@ -220,4 +220,16 @@ export interface ChatMessageVo {
|
||||
* 用户id
|
||||
*/
|
||||
userId?: number;
|
||||
/**
|
||||
* 用户消息中的图片列表(前端扩展字段)
|
||||
*/
|
||||
images?: Array<{ url: string; name?: string }>;
|
||||
/**
|
||||
* 用户消息中的文件列表(前端扩展字段)
|
||||
*/
|
||||
files?: Array<{ name: string; size: number }>;
|
||||
/**
|
||||
* 创建时间(前端显示用)
|
||||
*/
|
||||
creationTime?: string;
|
||||
}
|
||||
|
||||
@@ -3,35 +3,52 @@
|
||||
import type { FileItem } from '@/stores/modules/files';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import mammoth from 'mammoth';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Popover from '@/components/Popover/index.vue';
|
||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
|
||||
// 配置 PDF.js worker - 使用稳定的 CDN
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
const filesStore = useFilesStore();
|
||||
|
||||
// 文件大小限制 3MB
|
||||
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||
|
||||
/* 弹出面板 开始 */
|
||||
const popoverStyle = ref({
|
||||
padding: '4px',
|
||||
height: 'fit-content',
|
||||
background: 'var(--el-bg-color, #fff)',
|
||||
border: '1px solid var(--el-border-color-light)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
const popoverRef = ref();
|
||||
/* 弹出面板 结束 */
|
||||
// 单个文件内容长度限制
|
||||
const MAX_TEXT_FILE_LENGTH = 50000; // 文本文件最大字符数
|
||||
const MAX_WORD_LENGTH = 30000; // Word 文档最大字符数
|
||||
const MAX_EXCEL_ROWS = 100; // Excel 最大行数
|
||||
const MAX_PDF_PAGES = 10; // PDF 最大页数
|
||||
|
||||
// 整个消息总长度限制(所有文件内容加起来,预估 token 安全限制)
|
||||
// 272000 tokens * 0.55 安全系数 ≈ 150000 字符
|
||||
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||
|
||||
const { reset, open, onChange } = useFileDialog({
|
||||
// 允许图片和文件
|
||||
accept: 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
directory: false, // 是否允许选择文件夹
|
||||
multiple: true, // 是否允许多选
|
||||
// 支持图片、文档、文本文件等
|
||||
accept: 'image/*,.txt,.log,.csv,.tsv,.md,.markdown,.json,.xml,.yaml,.yml,.toml,.ini,.conf,.config,.properties,.prop,.env,'
|
||||
+ '.js,.jsx,.ts,.tsx,.vue,.html,.htm,.css,.scss,.sass,.less,.styl,'
|
||||
+ '.java,.c,.cpp,.h,.hpp,.cs,.py,.rb,.go,.rs,.swift,.kt,.php,.sh,.bash,.zsh,.fish,.bat,.cmd,.ps1,'
|
||||
+ '.sql,.graphql,.proto,.thrift,'
|
||||
+ '.dockerfile,.gitignore,.gitattributes,.editorconfig,.npmrc,.nvmrc,'
|
||||
+ '.sln,.csproj,.vbproj,.fsproj,.props,.targets,'
|
||||
+ '.xlsx,.xls,.csv,.docx,.pdf',
|
||||
directory: false,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
// 压缩图片
|
||||
/**
|
||||
* 压缩图片
|
||||
* @param {File} file - 原始图片文件
|
||||
* @param {number} maxWidth - 最大宽度,默认 1024px
|
||||
* @param {number} maxHeight - 最大高度,默认 1024px
|
||||
* @param {number} quality - 压缩质量,0-1之间,默认 0.8
|
||||
* @returns {Promise<Blob>} 压缩后的图片 Blob
|
||||
*/
|
||||
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -60,12 +77,13 @@ function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality =
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
reject(new Error('压缩失败'));
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality
|
||||
quality,
|
||||
);
|
||||
};
|
||||
img.onerror = reject;
|
||||
@@ -76,7 +94,11 @@ function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality =
|
||||
});
|
||||
}
|
||||
|
||||
// 将 Blob 转换为 base64
|
||||
/**
|
||||
* 将 Blob 转换为 base64 格式
|
||||
* @param {Blob} blob - 要转换的 Blob 对象
|
||||
* @returns {Promise<string>} base64 编码的字符串(包含 data:xxx;base64, 前缀)
|
||||
*/
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -88,11 +110,289 @@ function blobToBase64(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文本文件内容
|
||||
* @param {File} file - 文本文件
|
||||
* @returns {Promise<string>} 文件内容字符串
|
||||
*/
|
||||
function readTextFile(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
* 通过 MIME 类型或文件扩展名判断
|
||||
* @param {File} file - 要判断的文件
|
||||
* @returns {boolean} 是否为文本文件
|
||||
*/
|
||||
function isTextFile(file: File): boolean {
|
||||
// 通过 MIME type 判断
|
||||
if (file.type.startsWith('text/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通过扩展名判断(更全面的列表)
|
||||
const textExtensions = [
|
||||
// 通用文本
|
||||
'txt',
|
||||
'log',
|
||||
'md',
|
||||
'markdown',
|
||||
'rtf',
|
||||
// 配置文件
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'conf',
|
||||
'config',
|
||||
'properties',
|
||||
'prop',
|
||||
'env',
|
||||
// 前端
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'vue',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'less',
|
||||
'styl',
|
||||
// 编程语言
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'cs',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'swift',
|
||||
'kt',
|
||||
'php',
|
||||
// 脚本
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
'bat',
|
||||
'cmd',
|
||||
'ps1',
|
||||
// 数据库/API
|
||||
'sql',
|
||||
'graphql',
|
||||
'proto',
|
||||
'thrift',
|
||||
// 版本控制/工具
|
||||
'dockerfile',
|
||||
'gitignore',
|
||||
'gitattributes',
|
||||
'editorconfig',
|
||||
'npmrc',
|
||||
'nvmrc',
|
||||
// .NET 项目文件
|
||||
'sln',
|
||||
'csproj',
|
||||
'vbproj',
|
||||
'fsproj',
|
||||
'props',
|
||||
'targets',
|
||||
// 数据文件
|
||||
'csv',
|
||||
'tsv',
|
||||
];
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return ext ? textExtensions.includes(ext) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel 文件,提取前 N 行数据转为 CSV 格式
|
||||
* @param {File} file - Excel 文件 (.xlsx, .xls)
|
||||
* @returns {Promise<{content: string, totalRows: number, extractedRows: number}>}
|
||||
* - content: CSV 格式的文本内容
|
||||
* - totalRows: 文件总行数
|
||||
* - extractedRows: 实际提取的行数(受 MAX_EXCEL_ROWS 限制)
|
||||
*/
|
||||
async function parseExcel(file: File): Promise<{ content: string; totalRows: number; extractedRows: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
let result = '';
|
||||
let totalRows = 0;
|
||||
let extractedRows = 0;
|
||||
|
||||
workbook.SheetNames.forEach((sheetName, index) => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// 获取工作表的范围
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
|
||||
const sheetTotalRows = range.e.r - range.s.r + 1;
|
||||
totalRows += sheetTotalRows;
|
||||
|
||||
// 限制行数
|
||||
const rowsToExtract = Math.min(sheetTotalRows, MAX_EXCEL_ROWS);
|
||||
extractedRows += rowsToExtract;
|
||||
|
||||
// 创建新的范围,只包含前 N 行
|
||||
const limitedRange = {
|
||||
s: { r: range.s.r, c: range.s.c },
|
||||
e: { r: range.s.r + rowsToExtract - 1, c: range.e.c },
|
||||
};
|
||||
|
||||
// 提取限制范围内的数据
|
||||
const limitedData: any[][] = [];
|
||||
for (let row = limitedRange.s.r; row <= limitedRange.e.r; row++) {
|
||||
const rowData: any[] = [];
|
||||
for (let col = limitedRange.s.c; col <= limitedRange.e.c; col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
rowData.push(cell ? cell.v : '');
|
||||
}
|
||||
limitedData.push(rowData);
|
||||
}
|
||||
|
||||
// 转换为 CSV
|
||||
const csvData = limitedData.map(row => row.join(',')).join('\n');
|
||||
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
result += `=== Sheet: ${sheetName} ===\n`;
|
||||
}
|
||||
result += csvData;
|
||||
if (index < workbook.SheetNames.length - 1) {
|
||||
result += '\n\n';
|
||||
}
|
||||
});
|
||||
|
||||
resolve({ content: result, totalRows, extractedRows });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Word 文档,提取纯文本内容
|
||||
* @param {File} file - Word 文档 (.docx)
|
||||
* @returns {Promise<{content: string, totalLength: number, extracted: boolean}>}
|
||||
* - content: 提取的文本内容
|
||||
* - totalLength: 原始文本总长度
|
||||
* - extracted: 是否被截断(超过 MAX_WORD_LENGTH)
|
||||
*/
|
||||
async function parseWord(file: File): Promise<{ content: string; totalLength: number; extracted: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer;
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
const fullText = result.value;
|
||||
const totalLength = fullText.length;
|
||||
|
||||
if (totalLength > MAX_WORD_LENGTH) {
|
||||
const truncated = fullText.substring(0, MAX_WORD_LENGTH);
|
||||
resolve({ content: truncated, totalLength, extracted: true });
|
||||
}
|
||||
else {
|
||||
resolve({ content: fullText, totalLength, extracted: false });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PDF 文件,提取前 N 页的文本内容
|
||||
* @param {File} file - PDF 文件
|
||||
* @returns {Promise<{content: string, totalPages: number, extractedPages: number}>}
|
||||
* - content: 提取的文本内容
|
||||
* - totalPages: 文件总页数
|
||||
* - extractedPages: 实际提取的页数(受 MAX_PDF_PAGES 限制)
|
||||
*/
|
||||
async function parsePDF(file: File): Promise<{ content: string; totalPages: number; extractedPages: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const typedArray = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const pdf = await pdfjsLib.getDocument(typedArray).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
const pagesToExtract = Math.min(totalPages, MAX_PDF_PAGES);
|
||||
|
||||
let fullText = '';
|
||||
for (let i = 1; i <= pagesToExtract; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items.map((item: any) => item.str).join(' ');
|
||||
fullText += `${pageText}\n`;
|
||||
}
|
||||
|
||||
resolve({ content: fullText, totalPages, extractedPages: pagesToExtract });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param {string} filename - 文件名
|
||||
* @returns {string} 小写的扩展名,无点号
|
||||
*/
|
||||
function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
onChange(async (files) => {
|
||||
if (!files)
|
||||
return;
|
||||
|
||||
const arr = [] as FileItem[];
|
||||
let totalContentLength = 0; // 跟踪总内容长度
|
||||
|
||||
// 先计算已有文件的总内容长度
|
||||
filesStore.filesList.forEach((f) => {
|
||||
if (f.fileType === 'text' && f.fileContent) {
|
||||
totalContentLength += f.fileContent.length;
|
||||
}
|
||||
// 图片 base64 也计入(虽然转 token 时不同,但也要计算)
|
||||
if (f.fileType === 'image' && f.base64) {
|
||||
// base64 转 token 比例约 1:1.5,这里保守估计
|
||||
totalContentLength += Math.floor(f.base64.length * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < files!.length; i++) {
|
||||
const file = files![i];
|
||||
@@ -103,143 +403,261 @@ onChange(async (files) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = getFileExtension(file.name);
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isExcel = ['xlsx', 'xls'].includes(ext);
|
||||
const isWord = ext === 'docx';
|
||||
const isPDF = ext === 'pdf';
|
||||
const isText = isTextFile(file);
|
||||
|
||||
// 压缩并转换为base64
|
||||
let base64 = '';
|
||||
let previewUrl = '';
|
||||
// 处理图片文件
|
||||
if (isImage) {
|
||||
try {
|
||||
// 先压缩图片
|
||||
const compressedBlob = await compressImage(file, 1024, 1024, 0.8);
|
||||
// 再转换为 base64
|
||||
base64 = await blobToBase64(compressedBlob);
|
||||
// 使用压缩后的图片作为预览
|
||||
previewUrl = base64;
|
||||
const base64 = await blobToBase64(compressedBlob);
|
||||
|
||||
// 检查总长度(base64 保守估计占用)
|
||||
const estimatedLength = Math.floor(base64.length * 0.5);
|
||||
if (totalContentLength + estimatedLength > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
ElMessage.error(`添加 ${file.name} 会超过消息总长度限制(${MAX_TOTAL_CONTENT_LENGTH} 字符),已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += estimatedLength;
|
||||
|
||||
// 计算压缩比例
|
||||
const originalSize = (file.size / 1024).toFixed(2);
|
||||
const compressedSize = (compressedBlob.size / 1024).toFixed(2);
|
||||
console.log(`图片压缩: ${file.name} - 原始: ${originalSize}KB, 压缩后: ${compressedSize}KB`);
|
||||
} catch (error) {
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: true,
|
||||
imgVariant: 'square',
|
||||
url: base64, // 使用压缩后的 base64 作为预览地址
|
||||
isUploaded: true,
|
||||
base64,
|
||||
fileType: 'image',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('压缩图片失败:', error);
|
||||
ElMessage.error(`${file.name} 压缩失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 Excel 文件
|
||||
else if (isExcel) {
|
||||
try {
|
||||
const result = await parseExcel(file);
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(), // 不写 uid,文件列表展示不出来,elx 1.2.0 bug 待修复
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true, // 显示删除图标
|
||||
imgPreview: isImage, // 图片才显示预览
|
||||
imgVariant: 'square', // 图片预览的形状
|
||||
url: isImage ? previewUrl : undefined, // 使用压缩后的 base64 作为预览地址
|
||||
isUploaded: true, // 直接标记为已完成
|
||||
base64: isImage ? base64 : undefined, // 保存压缩后的base64
|
||||
});
|
||||
// 检查总长度
|
||||
if (totalContentLength + result.content.length > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
ElMessage.error(`添加 ${file.name} 会超过消息总长度限制(${MAX_TOTAL_CONTENT_LENGTH} 字符),已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += result.content.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: result.content,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (result.totalRows > MAX_EXCEL_ROWS) {
|
||||
ElMessage.warning(`${file.name} 共 ${result.totalRows} 行,已提取前 ${result.extractedRows} 行`);
|
||||
}
|
||||
|
||||
console.log(`Excel 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总行数: ${result.totalRows}, 已提取: ${result.extractedRows} 行, 内容长度: ${result.content.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 Excel 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 Word 文档
|
||||
else if (isWord) {
|
||||
try {
|
||||
const result = await parseWord(file);
|
||||
|
||||
// 检查总长度
|
||||
if (totalContentLength + result.content.length > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
ElMessage.error(`添加 ${file.name} 会超过消息总长度限制(${MAX_TOTAL_CONTENT_LENGTH} 字符),已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += result.content.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: result.content,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (result.extracted) {
|
||||
ElMessage.warning(`${file.name} 共 ${result.totalLength} 字符,已提取前 ${MAX_WORD_LENGTH} 字符`);
|
||||
}
|
||||
|
||||
console.log(`Word 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总长度: ${result.totalLength}, 已提取: ${result.content.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 Word 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 PDF 文件
|
||||
else if (isPDF) {
|
||||
try {
|
||||
const result = await parsePDF(file);
|
||||
|
||||
// 检查总长度
|
||||
if (totalContentLength + result.content.length > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
ElMessage.error(`添加 ${file.name} 会超过消息总长度限制(${MAX_TOTAL_CONTENT_LENGTH} 字符),已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += result.content.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: result.content,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (result.totalPages > MAX_PDF_PAGES) {
|
||||
ElMessage.warning(`${file.name} 共 ${result.totalPages} 页,已提取前 ${result.extractedPages} 页`);
|
||||
}
|
||||
|
||||
console.log(`PDF 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总页数: ${result.totalPages}, 已提取: ${result.extractedPages} 页, 内容长度: ${result.content.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 PDF 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理文本文件
|
||||
else if (isText) {
|
||||
try {
|
||||
// 读取文本文件内容
|
||||
const content = await readTextFile(file);
|
||||
|
||||
// 限制单个文本文件长度
|
||||
let finalContent = content;
|
||||
let truncated = false;
|
||||
if (content.length > MAX_TEXT_FILE_LENGTH) {
|
||||
finalContent = content.substring(0, MAX_TEXT_FILE_LENGTH);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// 检查总长度
|
||||
if (totalContentLength + finalContent.length > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
ElMessage.error(`添加 ${file.name} 会超过消息总长度限制(${MAX_TOTAL_CONTENT_LENGTH} 字符),已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += finalContent.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: finalContent,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (truncated) {
|
||||
ElMessage.warning(`${file.name} 共 ${content.length} 字符,已提取前 ${MAX_TEXT_FILE_LENGTH} 字符`);
|
||||
}
|
||||
|
||||
console.log(`文本文件读取: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 内容长度: ${content.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('读取文件失败:', error);
|
||||
ElMessage.error(`${file.name} 读取失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 不支持的文件类型
|
||||
else {
|
||||
ElMessage.warning(`${file.name} 不是支持的文件类型`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.length > 0) {
|
||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||
ElMessage.success(`已添加 ${arr.length} 个文件`);
|
||||
ElMessage.success(`已添加 ${arr.length} 个文件,当前总内容长度约 ${totalContentLength} 字符`);
|
||||
}
|
||||
|
||||
// 重置文件选择器
|
||||
nextTick(() => reset());
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开文件选择对话框
|
||||
*/
|
||||
function handleUploadFiles() {
|
||||
open();
|
||||
popoverRef.value.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-select">
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
placement="top-start"
|
||||
:offset="[4, 0]"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
trigger="clickTarget"
|
||||
<!-- 直接点击上传,添加 tooltip 提示 -->
|
||||
<el-tooltip
|
||||
content="上传文件或图片(支持 Excel、Word、PDF、代码文件等,最大3MB)"
|
||||
placement="top"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<el-icon>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="handleUploadFiles"
|
||||
>
|
||||
<el-icon>
|
||||
<Upload />
|
||||
</el-icon>
|
||||
<div class="font-size-14px">
|
||||
上传文件或图片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
placement="right-end"
|
||||
:offset="[8, 4]"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
trigger="hover"
|
||||
:hover-delay="100"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<SvgIcon name="code" size="16" />
|
||||
<div class="font-size-14px">
|
||||
上传代码
|
||||
</div>
|
||||
|
||||
<el-icon class="ml-auto">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="
|
||||
() => {
|
||||
ElMessage.warning('暂未开放');
|
||||
}
|
||||
"
|
||||
>
|
||||
代码文件
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="
|
||||
() => {
|
||||
ElMessage.warning('暂未开放');
|
||||
}
|
||||
"
|
||||
>
|
||||
代码文件夹
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<div
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="handleUploadFiles"
|
||||
>
|
||||
<el-icon>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</div>
|
||||
</Popover>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
thinkingStatus: 'end',
|
||||
content: extractThkContentAfter(item.content as string),
|
||||
thinlCollapse: false,
|
||||
// 保留图片和文件信息
|
||||
images: item.images,
|
||||
files: item.files,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface FileItem extends FilesCardProps {
|
||||
isUploaded?: boolean; // 是否已上传
|
||||
uploadProgress?: number; // 上传进度
|
||||
base64?: string; // 图片的base64编码
|
||||
fileContent?: string; // 文本文件的内容
|
||||
fileType?: 'image' | 'text'; // 文件类型
|
||||
}
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
|
||||
Reference in New Issue
Block a user