diff --git a/Yi.Ai.Vue3/src/composables/chat/index.ts b/Yi.Ai.Vue3/src/composables/chat/index.ts new file mode 100644 index 00000000..8d945f5e --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/chat/index.ts @@ -0,0 +1,6 @@ +// Chat 相关 composables 统一导出 + +export * from './useImageCompression'; +export * from './useFilePaste'; +export * from './useFileParsing'; +export * from './useChatSender'; diff --git a/Yi.Ai.Vue3/src/composables/chat/useChatSender.ts b/Yi.Ai.Vue3/src/composables/chat/useChatSender.ts new file mode 100644 index 00000000..6c5ad5fd --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/chat/useChatSender.ts @@ -0,0 +1,301 @@ +import { useHookFetch } from 'hook-fetch/vue'; +import { ElMessage } from 'element-plus'; +import { ref, computed } from 'vue'; +import type { AnyObject } from 'typescript-api-pro'; +import { deleteMessages, unifiedSend } from '@/api'; +import { useModelStore } from '@/stores/modules/model'; +import { convertToApiFormat, parseStreamChunk, type UnifiedMessage } from '@/utils/apiFormatConverter'; +import type { BubbleProps } from 'vue-element-plus-x/types/Bubble'; +import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking'; + +export type MessageRole = 'ai' | 'user' | 'assistant' | string; + +export interface MessageItem extends BubbleProps { + key: number | string; + id?: number | string; + role: MessageRole; + avatar?: string; + showAvatar?: boolean; + thinkingStatus?: ThinkingStatus; + thinlCollapse?: boolean; + reasoning_content?: string; + images?: Array<{ url: string; name?: string }>; + files?: Array<{ name: string; size: number }>; + creationTime?: string; + tokenUsage?: { prompt: number; completion: number; total: number }; +} + +export interface UseChatSenderOptions { + sessionId: string; + onError?: (error: any) => void; + onMessageComplete?: () => void; +} + +// 创建统一发送请求的包装函数 +function unifiedSendWrapper(params: any) { + const { data, apiType, modelId, sessionId } = params; + return unifiedSend(data, apiType, modelId, sessionId); +} + +/** + * Composable: 聊天发送逻辑 + */ +export function useChatSender(options: UseChatSenderOptions) { + const { sessionId, onError, onMessageComplete } = options; + const modelStore = useModelStore(); + + const isSending = ref(false); + const isThinking = ref(false); + const currentRequestApiType = ref(''); + + // 临时ID计数器 + let tempIdCounter = -1; + + const { stream, loading: isLoading, cancel } = useHookFetch({ + request: unifiedSendWrapper, + onError: async (error) => { + isLoading.value = false; + if (error.status === 403) { + const data = await error.response.json(); + ElMessage.error(data.error.message); + return Promise.reject(data); + } + if (error.status === 401) { + ElMessage.error('登录已过期,请重新登录!'); + // 需要访问 userStore,这里通过回调处理 + onError?.(error); + } + }, + }); + + /** + * 处理流式响应的数据块 + */ + function handleDataChunk( + chunk: AnyObject, + messages: MessageItem[], + onUpdate: (messages: MessageItem[]) => void, + ) { + try { + const parsed = parseStreamChunk( + chunk, + currentRequestApiType.value || 'Completions', + ); + + const latest = messages[messages.length - 1]; + if (!latest) return; + + // 处理 token 使用情况 + if (parsed.usage) { + latest.tokenUsage = { + prompt: parsed.usage.prompt_tokens || 0, + completion: parsed.usage.completion_tokens || 0, + total: parsed.usage.total_tokens || 0, + }; + } + + // 处理推理内容 + if (parsed.reasoning_content) { + latest.thinkingStatus = 'thinking'; + latest.loading = true; + latest.thinlCollapse = true; + if (!latest.reasoning_content) latest.reasoning_content = ''; + latest.reasoning_content += parsed.reasoning_content; + } + + // 处理普通内容 + if (parsed.content) { + const thinkStart = parsed.content.includes(''); + const thinkEnd = parsed.content.includes(''); + + if (thinkStart) isThinking.value = true; + if (thinkEnd) isThinking.value = false; + + if (isThinking.value) { + latest.thinkingStatus = 'thinking'; + latest.loading = true; + latest.thinlCollapse = true; + if (!latest.reasoning_content) latest.reasoning_content = ''; + latest.reasoning_content += parsed.content + .replace('', '') + .replace('', ''); + } else { + latest.thinkingStatus = 'end'; + latest.loading = false; + if (!latest.content) latest.content = ''; + latest.content += parsed.content; + } + } + + onUpdate([...messages]); + } + catch (err) { + console.error('解析数据时出错:', err); + } + } + + /** + * 发送消息 + */ + async function sendMessage( + chatContent: string, + messages: MessageItem[], + imageFiles: any[], + textFiles: any[], + onUpdate: (messages: MessageItem[]) => void, + ): Promise { + if (isSending.value) return; + + isSending.value = true; + currentRequestApiType.value = modelStore.currentModelInfo.modelApiType || 'Completions'; + + try { + // 添加用户消息和AI消息 + const tempId = tempIdCounter--; + const userMessage: MessageItem = { + key: tempId, + id: tempId, + role: 'user', + placement: 'end', + isMarkdown: false, + loading: false, + content: chatContent, + images: imageFiles.length > 0 + ? imageFiles.map(f => ({ url: f.base64!, name: f.name })) + : undefined, + files: textFiles.length > 0 + ? textFiles.map(f => ({ name: f.name!, size: f.fileSize! })) + : undefined, + shape: 'corner', + }; + + const aiTempId = tempIdCounter--; + const aiMessage: MessageItem = { + key: aiTempId, + id: aiTempId, + role: 'assistant', + placement: 'start', + isMarkdown: true, + loading: true, + content: '', + reasoning_content: '', + thinkingStatus: 'start', + thinlCollapse: false, + noStyle: true, + }; + + messages = [...messages, userMessage, aiMessage]; + onUpdate(messages); + + // 组装消息内容 + const messagesContent = messages.slice(0, -1).slice(-6).map((item: MessageItem) => { + const baseMessage: any = { role: item.role }; + + if (item.role === 'user' && item.key === messages.length - 2) { + const contentArray: any[] = []; + + if (item.content) { + contentArray.push({ type: 'text', text: item.content }); + } + + // 添加文本文件内容 + if (textFiles.length > 0) { + let fileContent = '\n\n'; + textFiles.forEach((fileItem, index) => { + fileContent += `\n`; + fileContent += `File ${index + 1}\n`; + fileContent += `${fileItem.name}\n`; + fileContent += `\n${fileItem.fileContent}\n\n`; + fileContent += `\n`; + if (index < textFiles.length - 1) fileContent += '\n'; + }); + contentArray.push({ type: 'text', text: fileContent }); + } + + // 添加图片 + imageFiles.forEach((fileItem) => { + if (fileItem.base64) { + contentArray.push({ + type: 'image_url', + image_url: { url: fileItem.base64, name: fileItem.name }, + }); + } + }); + + baseMessage.content = contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0 + ? contentArray + : item.content; + } else { + baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000 + ? `${item.content.substring(0, 10000)}...(内容过长,已省略)` + : item.content; + } + + return baseMessage; + }); + + const apiType = modelStore.currentModelInfo.modelApiType || 'Completions'; + const convertedRequest = convertToApiFormat( + messagesContent as UnifiedMessage[], + apiType, + modelStore.currentModelInfo.modelId ?? '', + true, + ); + + const modelId = modelStore.currentModelInfo.modelId ?? ''; + + for await (const chunk of stream({ + data: convertedRequest, + apiType, + modelId, + sessionId, + })) { + handleDataChunk(chunk.result as AnyObject, messages, onUpdate); + } + } + catch (err: any) { + if (err.name !== 'AbortError') { + console.error('Fetch error:', err); + onError?.(err); + } + } + finally { + isSending.value = false; + const latest = messages[messages.length - 1]; + if (latest) { + latest.typing = false; + latest.loading = false; + if (latest.thinkingStatus === 'thinking') { + latest.thinkingStatus = 'end'; + } + } + onUpdate([...messages]); + onMessageComplete?.(); + } + } + + /** + * 取消发送 + */ + function cancelSend(messages: MessageItem[], onUpdate: (messages: MessageItem[]) => void) { + cancel(); + isSending.value = false; + const latest = messages[messages.length - 1]; + if (latest) { + latest.typing = false; + latest.loading = false; + if (latest.thinkingStatus === 'thinking') { + latest.thinkingStatus = 'end'; + } + } + onUpdate([...messages]); + } + + return { + isSending, + isLoading, + sendMessage, + cancelSend, + handleDataChunk, + }; +} diff --git a/Yi.Ai.Vue3/src/composables/chat/useFileParsing.ts b/Yi.Ai.Vue3/src/composables/chat/useFileParsing.ts new file mode 100644 index 00000000..c62d15b9 --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/chat/useFileParsing.ts @@ -0,0 +1,254 @@ +import mammoth from 'mammoth'; +import * as pdfjsLib from 'pdfjs-dist'; +import * as XLSX from 'xlsx'; +import { ElMessage } from 'element-plus'; + +// 配置 PDF.js worker +pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`; + +export interface ParseOptions { + maxTextFileLength?: number; + maxWordLength?: number; + maxExcelRows?: number; + maxPdfPages?: number; +} + +export interface ParsedResult { + content: string; + isTruncated: boolean; + totalSize?: number; + extractedSize?: number; +} + +// 文本文件扩展名列表 +const TEXT_EXTENSIONS = [ + 'txt', 'log', 'md', 'markdown', 'json', 'xml', 'yaml', 'yml', 'toml', + 'ini', 'conf', 'config', 'js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', + 'css', 'scss', 'sass', 'less', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', + 'py', 'rb', 'go', 'rs', 'swift', 'kt', 'php', 'sh', 'bash', 'sql', + 'csv', 'tsv', +]; + +/** + * 判断是否为文本文件 + */ +export function isTextFile(file: File): boolean { + if (file.type.startsWith('text/')) return true; + const ext = file.name.split('.').pop()?.toLowerCase(); + return ext ? TEXT_EXTENSIONS.includes(ext) : false; +} + +/** + * 读取文本文件 + */ +export function readTextFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(file, 'UTF-8'); + }); +} + +/** + * 解析 Excel 文件 + */ +export async function parseExcel( + file: File, + maxRows = 100, +): 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, maxRows); + extractedRows += rowsToExtract; + + const limitedData: any[][] = []; + for (let row = range.s.r; row < range.s.r + rowsToExtract; row++) { + const rowData: any[] = []; + for (let col = range.s.c; col <= range.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); + } + + 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 文档 + */ +export async function parseWord( + file: File, + maxLength = 30000, +): 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 > maxLength) { + const truncated = fullText.substring(0, maxLength); + resolve({ content: truncated, totalLength, extracted: true }); + } + else { + resolve({ content: fullText, totalLength, extracted: false }); + } + } + catch (error) { + reject(error); + } + }; + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); +} + +/** + * 解析 PDF 文件 + */ +export async function parsePDF( + file: File, + maxPages = 10, +): 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, maxPages); + + 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); + }); +} + +/** + * 解析文件内容(自动识别类型) + */ +export async function parseFileContent( + file: File, + options: ParseOptions = {}, +): Promise { + const { + maxTextFileLength = 50000, + maxWordLength = 30000, + maxExcelRows = 100, + maxPdfPages = 10, + } = options; + + const fileName = file.name.toLowerCase(); + + // 文本文件 + if (isTextFile(file) && !fileName.endsWith('.pdf')) { + const content = await readTextFile(file); + const isTruncated = content.length > maxTextFileLength; + return { + content: isTruncated ? content.substring(0, maxTextFileLength) : content, + isTruncated, + totalSize: content.length, + extractedSize: isTruncated ? maxTextFileLength : content.length, + }; + } + + // Excel 文件 + if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls') || fileName.endsWith('.csv')) { + const result = await parseExcel(file, maxExcelRows); + return { + content: result.content, + isTruncated: result.totalRows > maxExcelRows, + totalSize: result.totalRows, + extractedSize: result.extractedRows, + }; + } + + // Word 文件 + if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) { + const result = await parseWord(file, maxWordLength); + return { + content: result.content, + isTruncated: result.extracted, + totalSize: result.totalLength, + extractedSize: result.extracted ? maxWordLength : result.totalLength, + }; + } + + // PDF 文件 + if (fileName.endsWith('.pdf')) { + const result = await parsePDF(file, maxPdfPages); + return { + content: result.content, + isTruncated: result.totalPages > maxPdfPages, + totalSize: result.totalPages, + extractedSize: result.extractedPages, + }; + } + + throw new Error(`不支持的文件类型: ${file.name}`); +} + +/** + * Composable: 文件解析 + */ +export function useFileParsing(options: ParseOptions = {}) { + return { + isTextFile, + readTextFile, + parseExcel, + parseWord, + parsePDF, + parseFileContent, + }; +} diff --git a/Yi.Ai.Vue3/src/composables/chat/useFilePaste.ts b/Yi.Ai.Vue3/src/composables/chat/useFilePaste.ts new file mode 100644 index 00000000..0f9352d6 --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/chat/useFilePaste.ts @@ -0,0 +1,144 @@ +import { ElMessage } from 'element-plus'; +import { useImageCompression, type CompressionLevel } from './useImageCompression'; +import type { FileItem } from '@/stores/modules/files'; + +export interface UseFilePasteOptions { + /** 最大文件大小 (字节) */ + maxFileSize?: number; + /** 最大总内容长度 */ + maxTotalContentLength?: number; + /** 压缩级别配置 */ + compressionLevels?: CompressionLevel[]; + /** 获取当前文件列表总长度 */ + getCurrentTotalLength: () => number; + /** 添加文件到列表 */ + addFiles: (files: FileItem[]) => void; + /** 是否只接受图片 (默认true) */ + imagesOnly?: boolean; +} + +/** + * 从剪贴板数据项中提取文件 + */ +function extractFilesFromItems(items: DataTransferItemList): File[] { + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + return files; +} + +/** + * Composable: 处理粘贴事件中的文件 + */ +export function useFilePaste(options: UseFilePasteOptions) { + const { + maxFileSize = 3 * 1024 * 1024, + maxTotalContentLength = 150000, + compressionLevels, + getCurrentTotalLength, + addFiles, + imagesOnly = true, + } = options; + + const { tryCompressToLimit } = useImageCompression(); + + /** + * 处理单个粘贴的文件 + */ + async function processPastedFile( + file: File, + currentTotalLength: number, + ): Promise { + // 验证文件大小 + if (file.size > maxFileSize) { + ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`); + return null; + } + + const isImage = file.type.startsWith('image/'); + + if (isImage) { + try { + const result = await tryCompressToLimit( + file, + currentTotalLength, + maxTotalContentLength, + compressionLevels, + ); + + if (!result) { + ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`); + return null; + } + + return { + uid: crypto.randomUUID(), + name: file.name, + fileSize: file.size, + file, + maxWidth: '200px', + showDelIcon: true, + imgPreview: true, + imgVariant: 'square', + url: result.base64, + isUploaded: true, + base64: result.base64, + fileType: 'image', + }; + } + catch (error) { + console.error('处理图片失败:', error); + ElMessage.error(`${file.name} 处理失败`); + return null; + } + } + else if (!imagesOnly) { + // 如果不是仅图片模式,可以在这里处理其他类型文件 + ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`); + return null; + } + + return null; + } + + /** + * 处理粘贴事件 + */ + async function handlePaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items) return; + + const files = extractFilesFromItems(items); + if (files.length === 0) return; + + event.preventDefault(); + + let totalContentLength = getCurrentTotalLength(); + const newFiles: FileItem[] = []; + + for (const file of files) { + const processedFile = await processPastedFile(file, totalContentLength); + if (processedFile) { + newFiles.push(processedFile); + totalContentLength += Math.floor((processedFile.base64?.length || 0) * 0.5); + } + } + + if (newFiles.length > 0) { + addFiles(newFiles); + ElMessage.success(`已添加 ${newFiles.length} 个文件`); + } + } + + return { + handlePaste, + processPastedFile, + }; +} diff --git a/Yi.Ai.Vue3/src/composables/chat/useImageCompression.ts b/Yi.Ai.Vue3/src/composables/chat/useImageCompression.ts new file mode 100644 index 00000000..ddcf4feb --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/chat/useImageCompression.ts @@ -0,0 +1,127 @@ +import { ElMessage } from 'element-plus'; + +export interface CompressionLevel { + maxWidth: number; + maxHeight: number; + quality: number; +} + +export const DEFAULT_COMPRESSION_LEVELS: CompressionLevel[] = [ + { maxWidth: 800, maxHeight: 800, quality: 0.6 }, + { maxWidth: 600, maxHeight: 600, quality: 0.5 }, + { maxWidth: 400, maxHeight: 400, quality: 0.4 }, +]; + +/** + * 压缩图片 + * @param file - 要压缩的图片文件 + * @param maxWidth - 最大宽度 + * @param maxHeight - 最大高度 + * @param quality - 压缩质量 (0-1) + * @returns 压缩后的 Blob + */ +export function compressImage( + file: File, + maxWidth = 1024, + maxHeight = 1024, + quality = 0.8, +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height); + width = width * ratio; + height = height * ratio; + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } + else { + reject(new Error('压缩失败')); + } + }, + file.type, + quality, + ); + }; + img.onerror = reject; + img.src = e.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** + * 将 Blob 转换为 base64 + * @param blob - Blob 对象 + * @returns base64 字符串 + */ +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +/** + * 尝试多级压缩直到满足大小限制 + * @param file - 图片文件 + * @param currentTotalLength - 当前已使用的总长度 + * @param maxTotalLength - 最大允许总长度 + * @returns 压缩结果或 null(如果无法满足限制) + */ +export async function tryCompressToLimit( + file: File, + currentTotalLength: number, + maxTotalLength: number, + compressionLevels = DEFAULT_COMPRESSION_LEVELS, +): Promise<{ blob: Blob; base64: string; estimatedLength: number } | null> { + for (const level of compressionLevels) { + const compressedBlob = await compressImage( + file, + level.maxWidth, + level.maxHeight, + level.quality, + ); + const base64 = await blobToBase64(compressedBlob); + const estimatedLength = Math.floor(base64.length * 0.5); + + if (currentTotalLength + estimatedLength <= maxTotalLength) { + return { blob: compressedBlob, base64, estimatedLength }; + } + } + return null; +} + +/** + * Composable: 使用图片压缩 + */ +export function useImageCompression() { + return { + compressImage, + blobToBase64, + tryCompressToLimit, + DEFAULT_COMPRESSION_LEVELS, + }; +} diff --git a/Yi.Ai.Vue3/src/composables/index.ts b/Yi.Ai.Vue3/src/composables/index.ts new file mode 100644 index 00000000..839e651c --- /dev/null +++ b/Yi.Ai.Vue3/src/composables/index.ts @@ -0,0 +1,2 @@ +// Composables 统一导出 +export * from './chat'; diff --git a/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue b/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue new file mode 100644 index 00000000..c8629b9e --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue b/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue new file mode 100644 index 00000000..09a9a22d --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue @@ -0,0 +1,206 @@ + + + + + emit('submit', v)" + @cancel="emit('cancel')" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue b/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue new file mode 100644 index 00000000..a4b5780a --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue @@ -0,0 +1,90 @@ + + + + + + 已选择 {{ selectedCount }} 条消息 + + + 确认删除 + + + 取消 + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue b/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue new file mode 100644 index 00000000..19340571 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue @@ -0,0 +1,555 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 发送 + + + 取消 + + + + + + + + + + + + + + + + + + + {{ file.name }} + + + + + + {{ item.content }} + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/index.ts b/Yi.Ai.Vue3/src/pages/chat/components/index.ts new file mode 100644 index 00000000..306d5172 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/index.ts @@ -0,0 +1,6 @@ +// Chat 页面组件统一导出 + +export { default as ChatHeader } from './ChatHeader.vue'; +export { default as ChatSender } from './ChatSender.vue'; +export { default as MessageItem } from './MessageItem.vue'; +export { default as DeleteModeToolbar } from './DeleteModeToolbar.vue'; diff --git a/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue b/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue index 3eefcc93..02e29158 100644 --- a/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue +++ b/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue @@ -1,61 +1,77 @@ - - - - - - - - - - - - - - - - - - - + + + - + - - 已选择 {{ selectedMessageIds.length }} 条消息 - - - 确认删除 - - - 取消 - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - 发送 - - - 取消 - - - - - - - - handleImagePreview(image.url)" - > - - - - - - - {{ file.name }} - - - - {{ item.content }} - - - - - - - - - - - - - - - - - - - - - - 发送 - - - 取消 - - - - - - - - handleImagePreview(image.url)" - > - - - - - - - - {{ file.name }} - - - - - {{ item.content }} - - - - - - - - - + + - - - - - - - - - - - - - - - - - - + + filesStore.deleteFileByIndex(index)" + /> - + - - - + @@ -1377,599 +595,139 @@ onUnmounted(() => { diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss b/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss new file mode 100644 index 00000000..d78e5482 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss @@ -0,0 +1,169 @@ +// 气泡列表相关样式 (需要 :deep 穿透) + +// 基础气泡列表样式 +@mixin bubble-list-base { + :deep(.el-bubble-list) { + padding-top: 24px; + + @media (max-width: 768px) { + padding-top: 16px; + } + } +} + +// 气泡基础样式 +@mixin bubble-base { + :deep(.el-bubble) { + padding: 0 12px 24px; + + // 隐藏头像 + .el-avatar { + display: none !important; + } + + // 用户消息样式 + &[class*="end"] { + width: 100%; + max-width: 100%; + + .el-bubble-content-wrapper { + flex: none; + max-width: fit-content; + } + + .el-bubble-content { + width: fit-content; + max-width: 100%; + } + } + + @media (max-width: 768px) { + padding: 0 8px 16px; + } + + @media (max-width: 480px) { + padding: 0 6px; + padding-bottom: 12px; + } + } +} + +// AI消息样式 +@mixin bubble-ai-style { + :deep(.el-bubble[class*="start"]) { + width: 100%; + max-width: 100%; + + .el-bubble-content-wrapper { + flex: auto; + } + + .el-bubble-content { + width: 100%; + max-width: 100%; + padding: 0; + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + } +} + +// 用户编辑模式样式 +@mixin bubble-edit-mode { + :deep(.el-bubble[class*="end"]) { + &:has(.edit-message-wrapper-full) { + .el-bubble-content-wrapper { + flex: auto; + max-width: 100%; + } + + .el-bubble-content { + width: 100%; + max-width: 100%; + } + } + } +} + +// 删除模式气泡样式 +@mixin bubble-delete-mode { + :deep(.el-bubble-list.delete-mode) { + .el-bubble { + &[class*="end"] .el-bubble-content-wrapper { + flex: auto; + max-width: 100%; + } + + .el-bubble-content { + position: relative; + min-height: 44px; + padding: 12px 16px; + background-color: #f5f7fa; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background-color: #e8f0fe; + border-color: #c6dafc; + } + } + + &:has(.el-checkbox.is-checked) .el-bubble-content { + background-color: #d2e3fc; + border-color: #8ab4f8; + } + } + + .delete-checkbox-inline { + position: absolute; + left: 12px; + top: 12px; + z-index: 2; + + :deep(.el-checkbox) { + --el-checkbox-input-height: 20px; + --el-checkbox-input-width: 20px; + } + } + + .ai-content-wrapper, + .user-content-wrapper { + margin-left: 36px; + width: calc(100% - 36px); + } + + .user-content-wrapper { + align-items: flex-start; + + .edit-message-wrapper-full { + width: 100%; + max-width: 100%; + } + } + } +} + +// Typewriter 样式 +@mixin typewriter-style { + :deep(.el-typewriter) { + overflow: hidden; + border-radius: 12px; + } +} + +// Markdown 容器样式 +@mixin markdown-container { + :deep(.elx-xmarkdown-container) { + padding: 8px 4px; + } +} + +// 代码块头部样式 +@mixin code-header { + :deep(.markdown-elxLanguage-header-div) { + top: -25px !important; + } +} diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/index.scss b/Yi.Ai.Vue3/src/pages/chat/styles/index.scss new file mode 100644 index 00000000..b8306d20 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/index.scss @@ -0,0 +1,5 @@ +// Chat 页面公共样式统一导入 + +@forward './variables'; +@forward './mixins'; +@forward './bubble'; diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss b/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss new file mode 100644 index 00000000..f80b6586 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss @@ -0,0 +1,102 @@ +// 聊天页面公共 mixins + +// 响应式 +@mixin respond-to($breakpoint) { + @if $breakpoint == tablet { + @media (max-width: 768px) { + @content; + } + } @else if $breakpoint == mobile { + @media (max-width: 480px) { + @content; + } + } +} + +// 弹性布局 +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +// 文本省略 +@mixin text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// 多行文本省略 +@mixin text-ellipsis-multi($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// 滚动按钮样式 +@mixin scroll-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + @include flex-center; + width: 22px; + height: 22px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + color: rgba(0, 0, 0, 0.4); + background-color: #fff; + font-size: 10px; + cursor: pointer; + z-index: 10; + transition: all 0.2s ease; + + &:hover { + background-color: #f3f4f6; + border-color: rgba(0, 0, 0, 0.15); + color: rgba(0, 0, 0, 0.6); + } +} + +// 操作按钮样式 +@mixin action-btn { + width: 24px; + height: 24px; + padding: 0; + font-size: 13px; + color: #555; + background: transparent; + border: none; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + color: #409eff; + background: #f0f7ff; + } + + &:active { + background: #e6f2ff; + } + + &[disabled] { + color: #bbb; + background: transparent; + } + + .el-icon { + font-size: 13px; + } +} diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss b/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss new file mode 100644 index 00000000..ef70f4ae --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss @@ -0,0 +1,56 @@ +// 聊天页面公共样式变量 + +// 布局 +$chat-header-height: 60px; +$chat-header-height-mobile: 50px; +$chat-header-height-small: 48px; + +$chat-max-width: 1000px; +$chat-padding: 20px; +$chat-padding-mobile: 12px; +$chat-padding-small: 8px; + +// 气泡列表 +$bubble-padding-y: 24px; +$bubble-padding-x: 12px; +$bubble-gap: 24px; + +$bubble-padding-y-mobile: 16px; +$bubble-padding-x-mobile: 8px; +$bubble-gap-mobile: 16px; + +// 用户消息 +$user-image-max-size: 200px; +$user-image-max-size-mobile: 150px; +$user-image-max-size-small: 120px; + +// 颜色 +$color-text-primary: #333; +$color-text-secondary: #888; +$color-text-tertiary: #bbb; + +$color-border: rgba(0, 0, 0, 0.08); +$color-border-hover: rgba(0, 0, 0, 0.15); + +$color-bg-hover: #f3f4f6; +$color-bg-light: #f5f7fa; +$color-bg-lighter: #e8f0fe; +$color-bg-selected: #d2e3fc; +$color-border-selected: #8ab4f8; + +$color-primary: #409eff; +$color-primary-light: #f0f7ff; +$color-primary-lighter: #e6f2ff; + +// 删除模式 +$color-delete-bg: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%); +$color-delete-border: #fed7aa; +$color-delete-text: #ea580c; + +// 动画 +$transition-fast: 0.2s ease; +$transition-normal: 0.3s ease; + +// 响应式断点 +$breakpoint-tablet: 768px; +$breakpoint-mobile: 480px; diff --git a/Yi.Ai.Vue3/src/stores/index.ts b/Yi.Ai.Vue3/src/stores/index.ts index cc1a71eb..a7041c70 100644 --- a/Yi.Ai.Vue3/src/stores/index.ts +++ b/Yi.Ai.Vue3/src/stores/index.ts @@ -8,7 +8,7 @@ store.use(piniaPluginPersistedstate); export default store; export * from './modules/announcement' -// export * from './modules/chat'; +export * from './modules/chat'; export * from './modules/design'; export * from './modules/user'; export * from './modules/guideTour';