| | |
| | | <div |
| | | v-for="(file, fileIndex) in message.localUploadFiles" |
| | | :key="`${file.previewId || file.name}-${fileIndex}`" |
| | | class="message-local-file-item" |
| | | :class="['message-local-file-item', { clickable: !!file.accessUrl && !file.isImage }]" |
| | | @click="handleMessageFileClick(file)" |
| | | > |
| | | <el-image |
| | | v-if="file.isImage && file.previewUrl" |
| | |
| | | /> |
| | | <el-icon v-else class="message-local-file-icon"><Document /></el-icon> |
| | | <div class="message-local-file-meta"> |
| | | <span class="message-local-file-name">{{ file.name }}</span> |
| | | <small class="message-local-file-size">{{ formatFileSize(file.size) }}</small> |
| | | <span |
| | | :class="['message-local-file-name', { clickable: !!file.accessUrl }]" |
| | | :title="file.name" |
| | | @click.stop="openMessageAttachment(file)" |
| | | > |
| | | {{ file.name }} |
| | | </span> |
| | | <small v-if="Number(file.size) > 0" class="message-local-file-size">{{ formatFileSize(file.size) }}</small> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue' |
| | | import request from '@/utils/request' |
| | | import * as echarts from 'echarts' |
| | | import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, ShoppingCart, Promotion, RefreshRight } from '@element-plus/icons-vue' |
| | | import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, Promotion, RefreshRight } from '@element-plus/icons-vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { builtInAssistants, generalAssistant } from './assistants' |
| | | |
| | | const props = defineProps({ |
| | | assistants: { |
| | |
| | | } |
| | | }) |
| | | |
| | | const builtInAssistants = [ |
| | | { |
| | | key: 'general', |
| | | label: '待办助理', |
| | | title: '待办智能助理', |
| | | tooltip: '待办助手', |
| | | icon: Cpu, |
| | | apiBase: '/xiaozhi', |
| | | storageKey: 'ai_chat_uuid', |
| | | placeholder: '请输入您的问题... (Enter 发送, Shift+Enter 换行)', |
| | | welcomeMessage: '你好', |
| | | description: '我可以回答你的问题,为你提供业务数据解读信息、处理建议和辅助决策支持。', |
| | | allowFileUpload: true, |
| | | emptySessionText: '暂无历史会话' |
| | | }, |
| | | { |
| | | key: 'purchase', |
| | | label: '采购助理', |
| | | title: '采购智能助理', |
| | | tooltip: '采购智能助理', |
| | | icon: ShoppingCart, |
| | | apiBase: '/purchase-ai', |
| | | storageKey: 'purchase_ai_chat_uuid', |
| | | placeholder: '请输入采购问题... (Enter 发送, Shift+Enter 换行)', |
| | | welcomeMessage: '你好', |
| | | description: '我可以协助你分析采购订单、到货进度、供应商表现和付款情况,帮助你快速定位采购异常。', |
| | | allowFileUpload: true, |
| | | allowMultipleFileUpload: true, |
| | | fileAnalyzeUrl: '/purchase-ai/analyze-files', |
| | | emptySessionText: '暂无采购会话' |
| | | } |
| | | ] |
| | | |
| | | const assistants = computed(() => props.assistants?.length ? props.assistants : builtInAssistants) |
| | | const selectedAssistantKey = ref(props.defaultAssistant || assistants.value[0]?.key || 'general') |
| | | const currentAssistant = computed(() => assistants.value.find(item => item.key === selectedAssistantKey.value) || assistants.value[0] || builtInAssistants[0]) |
| | | const showAssistantSwitch = computed(() => assistants.value.length > 1) |
| | | const assistantQuickPromptMap = { |
| | | general: [ |
| | | '我当前有哪些审批待办需要处理?', |
| | | '帮我列出今天新增的审批待办。', |
| | | '当前待我审批的单据,按时间倒序列出来。', |
| | | '我发起的审批里,哪些还在处理中?', |
| | | '查询流程编号 XXX 的审批详情。', |
| | | '流程编号 XXX 现在卡在哪个审批节点?当前审批人是谁?', |
| | | '帮我查看流程编号 XXX 的审批流转记录。', |
| | | '近7天我的审批待办统计情况怎么样?', |
| | | '本月我的审批中,通过、驳回、处理中各有多少?', |
| | | '近30天各类型审批数量分布是什么?', |
| | | '帮我审批通过流程编号 XXX,备注“同意”。', |
| | | '帮我驳回流程编号 XXX,备注“请补充说明”。', |
| | | '撤销我刚刚对流程编号 XXX 的审批操作。', |
| | | '帮我修改流程编号 XXX 的备注为“已补充附件”。', |
| | | '删除我发起的流程编号 XXX。' |
| | | ], |
| | | purchase: [ |
| | | '本月采购金额排名前十的物料有哪些?', |
| | | '哪些采购订单还未入库?', |
| | | '最近7天供应商到货异常有哪些?', |
| | | '帮我统计待付款采购单', |
| | | '列出本月采购退货情况' |
| | | ] |
| | | } |
| | | const quickPromptLimit = 3 |
| | | const quickPromptStart = ref(0) |
| | | const quickPrompts = computed(() => { |
| | |
| | | if (Array.isArray(assistant.quickPrompts) && assistant.quickPrompts.length) { |
| | | return assistant.quickPrompts |
| | | } |
| | | return assistantQuickPromptMap[assistant.key] || assistantQuickPromptMap.general |
| | | return generalAssistant.quickPrompts || [] |
| | | }) |
| | | const displayedQuickPrompts = computed(() => { |
| | | const prompts = quickPrompts.value || [] |
| | |
| | | const loadingSessions = ref(false) |
| | | |
| | | const isImageFileType = (fileType = '') => String(fileType || '').toLowerCase().startsWith('image/') |
| | | const imageFilePathPattern = /\.(png|jpe?g|gif|webp|bmp|svg)$/i |
| | | |
| | | const getPathnameFromFilePath = (filePath = '') => { |
| | | const rawPath = String(filePath || '').trim() |
| | | if (!rawPath) return '' |
| | | try { |
| | | const baseOrigin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' |
| | | return new URL(rawPath, baseOrigin).pathname || '' |
| | | } catch (err) { |
| | | return rawPath.split('?')[0] |
| | | } |
| | | } |
| | | |
| | | const isImageFilePath = (filePath = '') => { |
| | | const pathname = getPathnameFromFilePath(filePath).toLowerCase() |
| | | return imageFilePathPattern.test(pathname) |
| | | } |
| | | |
| | | const getHistoryFileName = (filePath = '', index = 0) => { |
| | | const pathname = getPathnameFromFilePath(filePath) |
| | | const fileName = pathname.split('/').filter(Boolean).pop() |
| | | if (!fileName) return `file-${index + 1}` |
| | | try { |
| | | return decodeURIComponent(fileName) |
| | | } catch (err) { |
| | | return fileName |
| | | } |
| | | } |
| | | |
| | | const getImagePreviewList = (files = []) => { |
| | | if (!Array.isArray(files)) return [] |
| | |
| | | const fileType = rawFile?.type || '' |
| | | const isImage = isImageFileType(fileType) |
| | | const canCreateObjectURL = typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function' |
| | | const previewUrl = isImage && rawFile && canCreateObjectURL ? URL.createObjectURL(rawFile) : '' |
| | | return { |
| | | previewId: `${rawFile?.name || 'file'}-${rawFile?.size || 0}-${rawFile?.lastModified || Date.now()}-${index}`, |
| | | name: rawFile?.name || `file-${index + 1}`, |
| | | size: rawFile?.size || 0, |
| | | type: fileType, |
| | | isImage, |
| | | previewUrl: isImage && rawFile && canCreateObjectURL ? URL.createObjectURL(rawFile) : '', |
| | | rawFile |
| | | previewUrl, |
| | | accessUrl: '', |
| | | rawFile, |
| | | isObjectUrl: !!previewUrl |
| | | } |
| | | } |
| | | |
| | | const createHistoryFileSnapshot = (filePath, memoryId = '', messageIndex = 0, fileIndex = 0) => { |
| | | const normalizedPath = String(filePath || '').trim() |
| | | if (!normalizedPath) return null |
| | | const isImage = isImageFilePath(normalizedPath) |
| | | return { |
| | | previewId: `${memoryId || 'history'}-${messageIndex}-${fileIndex}`, |
| | | name: getHistoryFileName(normalizedPath, fileIndex), |
| | | size: 0, |
| | | type: '', |
| | | isImage, |
| | | previewUrl: isImage ? normalizedPath : '', |
| | | accessUrl: normalizedPath, |
| | | rawFile: null, |
| | | isObjectUrl: false |
| | | } |
| | | } |
| | | |
| | |
| | | if (!canRevokeObjectURL) return |
| | | if (!Array.isArray(snapshots)) return |
| | | snapshots.forEach((snapshot) => { |
| | | if (snapshot?.previewUrl) { |
| | | if (snapshot?.isObjectUrl && snapshot?.previewUrl) { |
| | | URL.revokeObjectURL(snapshot.previewUrl) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | const mapHistoryFilePathsToSnapshots = (filePaths = [], memoryId = '', messageIndex = 0) => { |
| | | if (!Array.isArray(filePaths)) return [] |
| | | return filePaths |
| | | .map((filePath, fileIndex) => createHistoryFileSnapshot(filePath, memoryId, messageIndex, fileIndex)) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | const openMessageAttachment = (file) => { |
| | | const accessUrl = String(file?.accessUrl || '').trim() |
| | | if (!accessUrl) return |
| | | if (typeof window === 'undefined' || typeof window.open !== 'function') return |
| | | window.open(accessUrl, '_blank', 'noopener,noreferrer') |
| | | } |
| | | |
| | | const handleMessageFileClick = (file) => { |
| | | if (!file?.accessUrl || file?.isImage) return |
| | | openMessageAttachment(file) |
| | | } |
| | | |
| | | const revokeMessageLocalFileSnapshots = (messageList = []) => { |
| | |
| | | |
| | | const messageObj = { |
| | | isUser, |
| | | content: msg.content, |
| | | content: msg.content || '', |
| | | htmlContent: '', |
| | | isTyping: false, |
| | | chartOptions: null, |
| | |
| | | type: '', |
| | | tableData: null, |
| | | payloadTreeData: null, |
| | | payloadHiddenData: null |
| | | payloadHiddenData: null, |
| | | localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : [] |
| | | } |
| | | |
| | | messages.value.push(messageObj) |
| | |
| | | } |
| | | |
| | | // 解析历史消息中的 JSON |
| | | const extracted = extractEmbeddedSuccessJson(msg.content) |
| | | const extracted = extractEmbeddedSuccessJson(msg.content || '') |
| | | if (extracted) { |
| | | applyStructuredMessageData(messageObj, extracted.data, botMsgIndex) |
| | | } |
| | | |
| | | updateOutputState(msg.content, botMsgIndex) |
| | | messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex) |
| | | updateOutputState(msg.content || '', botMsgIndex) |
| | | messageObj.htmlContent = convertStreamOutput(msg.content || '', botMsgIndex) |
| | | } else { |
| | | messageObj.htmlContent = convertTextToHtml(msg.content) |
| | | messageObj.htmlContent = convertTextToHtml(msg.content || '') |
| | | } |
| | | }) |
| | | scrollToBottom() |
| | |
| | | border: 1px solid rgba(88, 117, 255, 0.2); |
| | | background: rgba(255, 255, 255, 0.9); |
| | | max-width: 100%; |
| | | |
| | | &.clickable { |
| | | cursor: pointer; |
| | | transition: all 0.2s ease; |
| | | |
| | | &:hover { |
| | | border-color: rgba(44, 109, 255, 0.38); |
| | | background: rgba(243, 247, 255, 0.96); |
| | | } |
| | | } |
| | | } |
| | | |
| | | .message-local-file-thumb { |
| | |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | |
| | | &.clickable { |
| | | color: $primary-blue; |
| | | cursor: pointer; |
| | | |
| | | &:hover { |
| | | text-decoration: underline; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .message-local-file-size { |