huminmin
9 天以前 1455e8a5dcea2209b4d1baf4d513aa8fbfb2b39b
src/components/AIChatSidebar/index.vue
@@ -191,7 +191,8 @@
                  <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"
@@ -205,8 +206,14 @@
                    />
                    <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>
@@ -515,8 +522,9 @@
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: {
@@ -529,69 +537,10 @@
  }
})
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(() => {
@@ -599,7 +548,7 @@
  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 || []
@@ -831,6 +780,34 @@
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 []
@@ -858,14 +835,34 @@
  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
  }
}
@@ -874,10 +871,29 @@
  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 = []) => {
@@ -962,7 +978,7 @@
        const messageObj = {
          isUser,
          content: msg.content,
          content: msg.content || '',
          htmlContent: '',
          isTyping: false,
          chartOptions: null,
@@ -970,7 +986,8 @@
          type: '',
          tableData: null,
          payloadTreeData: null,
          payloadHiddenData: null
          payloadHiddenData: null,
          localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : []
        }
        messages.value.push(messageObj)
@@ -985,15 +1002,15 @@
          }
          // 解析历史消息中的 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()
@@ -3509,6 +3526,16 @@
      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 {
@@ -3547,6 +3574,15 @@
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      &.clickable {
        color: $primary-blue;
        cursor: pointer;
        &:hover {
          text-decoration: underline;
        }
      }
    }
    .message-local-file-size {