huminmin
8 天以前 1455e8a5dcea2209b4d1baf4d513aa8fbfb2b39b
src/components/AIChatSidebar/index.vue
@@ -187,6 +187,37 @@
                <!-- 文本内容 -->
                <div class="text-box" v-html="message.htmlContent"></div>
                <div v-if="message.localUploadFiles?.length" class="message-local-file-list">
                  <div
                      v-for="(file, fileIndex) in message.localUploadFiles"
                      :key="`${file.previewId || file.name}-${fileIndex}`"
                      :class="['message-local-file-item', { clickable: !!file.accessUrl && !file.isImage }]"
                      @click="handleMessageFileClick(file)"
                  >
                    <el-image
                        v-if="file.isImage && file.previewUrl"
                        :src="file.previewUrl"
                        :preview-src-list="getImagePreviewList(message.localUploadFiles)"
                        :initial-index="getImagePreviewInitialIndex(message.localUploadFiles, file.previewUrl)"
                        :z-index="4000"
                        preview-teleported
                        fit="cover"
                        class="message-local-file-thumb"
                    />
                    <el-icon v-else class="message-local-file-icon"><Document /></el-icon>
                    <div class="message-local-file-meta">
                      <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>
                <!-- 图表内容 -->
                <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
                  <div
@@ -443,9 +474,22 @@
            </div>
            <div class="input-box">
              <div v-if="selectedFiles.length" class="selected-file-list">
                <div v-for="(file, fileIndex) in selectedFiles" :key="`${file.name}-${fileIndex}`" class="selected-file-tag">
                  <el-icon><Document /></el-icon>
                  <span class="file-name">{{ file.name }}</span>
                <div v-for="(file, fileIndex) in selectedFileSnapshots" :key="`${file.previewId || file.name}-${fileIndex}`" class="selected-file-tag">
                  <el-image
                      v-if="file.isImage && file.previewUrl"
                      :src="file.previewUrl"
                      :preview-src-list="getImagePreviewList(selectedFileSnapshots)"
                      :initial-index="getImagePreviewInitialIndex(selectedFileSnapshots, file.previewUrl)"
                      :z-index="4000"
                      preview-teleported
                      fit="cover"
                      class="selected-file-thumb"
                  />
                  <el-icon v-else><Document /></el-icon>
                  <div class="selected-file-meta">
                    <span class="file-name">{{ file.name }}</span>
                    <small class="file-size">{{ formatFileSize(file.size) }}</small>
                  </div>
                  <el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon>
                </div>
              </div>
@@ -478,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: {
@@ -492,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(() => {
@@ -562,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 || []
@@ -589,6 +575,7 @@
const inputMessage = ref('')
const selectedFiles = ref([])
const uploadFileList = ref([])
const selectedFileSnapshots = ref([])
const messages = ref([])
const uuid = ref('')
const chartInstances = ref({})
@@ -792,6 +779,142 @@
const sessions = ref([])
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 []
  return files
    .filter(item => item?.isImage && item?.previewUrl)
    .map(item => item.previewUrl)
}
const getImagePreviewInitialIndex = (files = [], previewUrl = '') => {
  const list = getImagePreviewList(files)
  const index = list.indexOf(previewUrl)
  return index >= 0 ? index : 0
}
const formatFileSize = (size) => {
  const bytes = Number(size)
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(/\.0$/, '')} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB`
}
const createLocalFileSnapshot = (file, index = 0) => {
  const rawFile = typeof File !== 'undefined' && file instanceof File ? file : null
  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,
    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
  }
}
const revokeLocalFileSnapshots = (snapshots = []) => {
  const canRevokeObjectURL = typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function'
  if (!canRevokeObjectURL) return
  if (!Array.isArray(snapshots)) return
  snapshots.forEach((snapshot) => {
    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 = []) => {
  if (!Array.isArray(messageList)) return
  messageList.forEach((msg) => {
    if (Array.isArray(msg?.localUploadFiles)) {
      revokeLocalFileSnapshots(msg.localUploadFiles)
      msg.localUploadFiles = []
    }
  })
}
const clearSelectedFiles = ({ releaseSnapshots = true } = {}) => {
  if (releaseSnapshots) {
    revokeLocalFileSnapshots(selectedFileSnapshots.value)
  }
  selectedFiles.value = []
  uploadFileList.value = []
  selectedFileSnapshots.value = []
}
const abortCurrentRequest = () => {
  if (!currentAbortController.value) return
@@ -835,6 +958,7 @@
const selectSession = async (session) => {
  showHistory.value = false
  clearSelectedFiles()
  uuid.value = session.memoryId
  localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
@@ -842,6 +966,7 @@
  try {
    const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`)
    if (res.code === 200) {
      revokeMessageLocalFileSnapshots(messages.value)
      disposeCharts()
      messages.value = []
      const historyMsgs = res.data || []
@@ -853,7 +978,7 @@
        const messageObj = {
          isUser,
          content: msg.content,
          content: msg.content || '',
          htmlContent: '',
          isTyping: false,
          chartOptions: null,
@@ -861,7 +986,8 @@
          type: '',
          tableData: null,
          payloadTreeData: null,
          payloadHiddenData: null
          payloadHiddenData: null,
          localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : []
        }
        messages.value.push(messageObj)
@@ -876,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()
@@ -928,6 +1054,8 @@
})
onUnmounted(() => {
  revokeMessageLocalFileSnapshots(messages.value)
  clearSelectedFiles()
  disposeCharts()
  window.removeEventListener('resize', handleWindowResize)
})
@@ -936,13 +1064,13 @@
  if (!prevKey || nextKey === prevKey) return
  abortCurrentRequest()
  revokeMessageLocalFileSnapshots(messages.value)
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFiles.value = []
  uploadFileList.value = []
  clearSelectedFiles()
  inputMessage.value = ''
  quickPromptStart.value = 0
  initUUID()
@@ -985,13 +1113,13 @@
}
const newChat = () => {
  revokeMessageLocalFileSnapshots(messages.value)
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFiles.value = []
  uploadFileList.value = []
  clearSelectedFiles()
  quickPromptStart.value = 0
  localStorage.removeItem(currentAssistant.value.storageKey)
  initUUID()
@@ -1667,6 +1795,76 @@
  '审批用户ID列表'
])
const purchaseIntegerFieldKeys = new Set([
  'id',
  'supplierId',
  'recorderId',
  'salesContractNoId',
  'salesLedgerId',
  'Type',
  'businessPersonId',
  'productId',
  'productModelId',
  'ticketRegistrationId',
  'type',
  'approvalStatus',
  'inventoryWarningQuantity'
])
const purchaseDecimalFieldKeys = new Set([
  'invoiceAmount',
  'contractAmount',
  'receiptPaymentAmount',
  'unReceiptPaymentAmount',
  'quantity',
  'taxRate',
  'taxInclusiveUnitPrice',
  'taxInclusiveTotalPrice',
  'taxExclusiveTotalPrice',
  'priceWithTax',
  'totalPriceWithTax'
])
const purchaseBooleanFieldKeys = new Set([
  'hasChildren',
  'isWhite',
  'isInspected',
  'isChecked'
])
const purchaseStringFieldKeys = new Set([
  'entryDateStart',
  'entryDateEnd',
  'purchaseContractNumber',
  'supplierName',
  'recorderName',
  'salesContractNo',
  'projectName',
  'entryDate',
  'executionDate',
  'remarks',
  'attachmentMaterials',
  'createdAt',
  'updatedAt',
  'phoneNumber',
  'invoiceNumber',
  'paymentMethod',
  'templateName',
  'productCategory',
  'specificationModel',
  'unit',
  'invoiceType'
])
const purchaseGenericArrayFieldKeys = new Set([
  'purchaseLedgers',
  'productData'
])
const purchaseStringArrayFieldKeys = new Set(['tempFileIds'])
const purchaseObjectArrayFieldKeys = new Set(['SalesLedgerFiles'])
const normalizePurchaseProductRecord = (record) => {
  if (!record || typeof record !== 'object' || Array.isArray(record)) return record
  return mapPayloadKeys(record, purchasePayloadFieldKeyMap)
@@ -1746,10 +1944,191 @@
  }, {})
}
const normalizeAttachmentMaterialsValue = (value) => {
  if (value === null || value === undefined) return value
  if (typeof value === 'string') return value
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
  try {
    return JSON.stringify(value)
  } catch (err) {
    return String(value)
  }
}
const normalizePurchaseAttachmentMaterialsField = (value) => {
  if (Array.isArray(value)) {
    return value.map(item => normalizePurchaseAttachmentMaterialsField(item))
  }
  if (value && typeof value === 'object') {
    return Object.entries(value).reduce((result, [key, item]) => {
      if (key === 'attachmentMaterials') {
        result[key] = normalizeAttachmentMaterialsValue(item)
      } else {
        result[key] = normalizePurchaseAttachmentMaterialsField(item)
      }
      return result
    }, {})
  }
  return value
}
const normalizePurchaseNumericText = (text = '') => {
  return String(text)
    .replace(/[,\s,]/g, '')
    .replace(/[¥¥元%]/g, '')
}
const parsePurchaseNumberValue = (value) => {
  if (typeof value === 'number') {
    return Number.isFinite(value) ? value : null
  }
  if (typeof value === 'boolean') {
    return value ? 1 : 0
  }
  if (typeof value !== 'string') {
    return null
  }
  const text = normalizePurchaseNumericText(value.trim())
  if (!text) return null
  if (!/^[-+]?\d+(\.\d+)?$/.test(text)) return null
  const numberValue = Number(text)
  return Number.isFinite(numberValue) ? numberValue : null
}
const normalizePurchaseIntegerFieldValue = (value) => {
  if (value === null || value === undefined) return value
  if (value === '') return null
  const numberValue = parsePurchaseNumberValue(value)
  if (numberValue === null) return null
  return Math.trunc(numberValue)
}
const normalizePurchaseDecimalFieldValue = (value) => {
  if (value === null || value === undefined) return value
  if (value === '') return null
  const numberValue = parsePurchaseNumberValue(value)
  return numberValue === null ? null : numberValue
}
const normalizePurchaseBooleanFieldValue = (value) => {
  if (value === null || value === undefined) return value
  if (value === '') return null
  if (typeof value === 'boolean') return value
  if (typeof value === 'number') return value !== 0
  if (typeof value !== 'string') return null
  const text = value.trim().toLowerCase()
  if (!text) return null
  if (['true', '1', 'yes', 'y', '是', '已', 'checked'].includes(text)) return true
  if (['false', '0', 'no', 'n', '否', '未', 'unchecked'].includes(text)) return false
  return null
}
const normalizePurchaseStringFieldValue = (value) => {
  if (value === null || value === undefined) return value
  if (typeof value === 'string') return value
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
  try {
    return JSON.stringify(value)
  } catch (err) {
    return String(value)
  }
}
const normalizePurchaseStringArrayFieldValue = (value) => {
  if (Array.isArray(value)) {
    return value
      .map(item => normalizePurchaseStringFieldValue(item))
      .filter(item => item !== null && item !== undefined && item !== '')
  }
  if (value === null || value === undefined || value === '') return []
  if (typeof value === 'string') {
    const text = value.trim()
    if (!text) return []
    if (/^\[.*\]$/.test(text)) {
      try {
        const parsedValue = JSON.parse(text)
        if (Array.isArray(parsedValue)) {
          return parsedValue
            .map(item => normalizePurchaseStringFieldValue(item))
            .filter(item => item !== null && item !== undefined && item !== '')
        }
      } catch (err) {
        // Keep as plain text when not valid JSON array.
      }
    }
    const splitValues = text
      .split(/[,\n,]/)
      .map(item => item.trim())
      .filter(Boolean)
    return splitValues.length > 1 ? splitValues : [text]
  }
  const normalizedValue = normalizePurchaseStringFieldValue(value)
  return normalizedValue === null || normalizedValue === undefined || normalizedValue === ''
    ? []
    : [normalizedValue]
}
const normalizePurchaseObjectArrayFieldValue = (value) => {
  if (Array.isArray(value)) {
    return value.filter(item => item && typeof item === 'object')
  }
  if (value && typeof value === 'object') {
    return [value]
  }
  return []
}
const normalizePurchaseValueByFieldKey = (fieldKey, value) => {
  if (fieldKey === 'attachmentMaterials') return normalizeAttachmentMaterialsValue(value)
  if (purchaseIntegerFieldKeys.has(fieldKey)) return normalizePurchaseIntegerFieldValue(value)
  if (purchaseDecimalFieldKeys.has(fieldKey)) return normalizePurchaseDecimalFieldValue(value)
  if (purchaseBooleanFieldKeys.has(fieldKey)) return normalizePurchaseBooleanFieldValue(value)
  if (purchaseStringArrayFieldKeys.has(fieldKey)) return normalizePurchaseStringArrayFieldValue(value)
  if (purchaseObjectArrayFieldKeys.has(fieldKey)) return normalizePurchaseObjectArrayFieldValue(value)
  if (purchaseStringFieldKeys.has(fieldKey)) return normalizePurchaseStringFieldValue(value)
  return value
}
const normalizePurchasePayloadFieldTypes = (value, fieldKey = '') => {
  if (purchaseGenericArrayFieldKeys.has(fieldKey)) {
    if (Array.isArray(value)) {
      return value.map(item => normalizePurchasePayloadFieldTypes(item))
    }
    if (value && typeof value === 'object') {
      return [normalizePurchasePayloadFieldTypes(value)]
    }
    return []
  }
  if (purchaseStringArrayFieldKeys.has(fieldKey)) {
    return normalizePurchaseStringArrayFieldValue(value)
  }
  if (purchaseObjectArrayFieldKeys.has(fieldKey)) {
    return normalizePurchaseObjectArrayFieldValue(value)
  }
  if (Array.isArray(value)) {
    return value.map(item => normalizePurchasePayloadFieldTypes(item))
  }
  if (value && typeof value === 'object') {
    return Object.entries(value).reduce((result, [key, item]) => {
      result[key] = normalizePurchasePayloadFieldTypes(item, key)
      return result
    }, {})
  }
  return normalizePurchaseValueByFieldKey(fieldKey, value)
}
const sanitizePurchasePayloadForSubmit = (payload, businessType) => {
  if (businessType !== 'purchase_ledger' || !payload || typeof payload !== 'object') return payload
  const sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload })
  let sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload })
  sanitized = normalizePurchaseAttachmentMaterialsField(sanitized)
  sanitized = normalizePurchasePayloadFieldTypes(sanitized)
  if (Array.isArray(sanitized.purchaseLedgers)) {
    sanitized.purchaseLedgers = sanitized.purchaseLedgers.map(filterPurchaseLedgerRecord)
  }
@@ -1954,16 +2333,20 @@
    return isLt10M
  })
  clearSelectedFiles()
  selectedFiles.value = validFiles
  uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw))
  selectedFileSnapshots.value = validFiles.map((rawFile, index) => createLocalFileSnapshot(rawFile, index))
}
const removeSelectedFile = (index) => {
  const [removedSnapshot] = selectedFileSnapshots.value.splice(index, 1)
  revokeLocalFileSnapshots(removedSnapshot ? [removedSnapshot] : [])
  selectedFiles.value.splice(index, 1)
  uploadFileList.value.splice(index, 1)
}
const analyzeFiles = async (files, message = '') => {
const analyzeFiles = async (files, message = '', localFileSnapshots = []) => {
  const uploadFiles = Array.isArray(files) ? files : [files].filter(Boolean)
  if (!uploadFiles.length) return
  if (isSending.value) return
@@ -1976,7 +2359,8 @@
    isUser: true,
    content: userMsg,
    htmlContent: convertTextToHtml(userMsg),
    isTyping: false
    isTyping: false,
    localUploadFiles: Array.isArray(localFileSnapshots) ? localFileSnapshots : []
  })
  const botMsgIndex = messages.value.length
@@ -2075,9 +2459,9 @@
  const msg = inputMessage.value?.trim() || ''
  if ((msg || selectedFiles.value.length) && !isSending.value) {
    if (selectedFiles.value.length) {
      analyzeFiles([...selectedFiles.value], msg)
      selectedFiles.value = []
      uploadFileList.value = []
      const localFileSnapshots = selectedFileSnapshots.value
      analyzeFiles([...selectedFiles.value], msg, localFileSnapshots)
      clearSelectedFiles({ releaseSnapshots: false })
    } else {
      sendRequest(msg)
    }
@@ -3125,6 +3509,87 @@
        border-radius: 2px;
      }
    }
    .message-local-file-list {
      margin-top: 8px;
      display: grid;
      gap: 8px;
      max-width: 100%;
    }
    .message-local-file-item {
      display: flex;
      align-items: center;
      gap: 10px;
      padding: 8px 10px;
      border-radius: 10px;
      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 {
      width: 40px;
      height: 40px;
      border-radius: 6px;
      overflow: hidden;
      flex-shrink: 0;
      border: 1px solid rgba(124, 148, 255, 0.26);
      background: #f4f7ff;
      cursor: zoom-in;
      :deep(.el-image__inner) {
        width: 100%;
        height: 100%;
      }
    }
    .message-local-file-icon {
      font-size: 20px;
      color: $primary-blue;
      flex-shrink: 0;
    }
    .message-local-file-meta {
      min-width: 0;
      display: flex;
      flex-direction: column;
      gap: 2px;
    }
    .message-local-file-name {
      font-size: 12px;
      color: #1f2a44;
      font-weight: 600;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      &.clickable {
        color: $primary-blue;
        cursor: pointer;
        &:hover {
          text-decoration: underline;
        }
      }
    }
    .message-local-file-size {
      font-size: 11px;
      color: #7f8ba1;
      line-height: 1.2;
    }
  }
  &.bot-message {
@@ -3537,6 +4002,28 @@
        font-size: 18px;
      }
      .selected-file-thumb {
        width: 30px;
        height: 30px;
        border-radius: 6px;
        overflow: hidden;
        border: 1px solid rgba(0, 85, 212, 0.2);
        flex-shrink: 0;
        cursor: zoom-in;
        :deep(.el-image__inner) {
          width: 100%;
          height: 100%;
        }
      }
      .selected-file-meta {
        min-width: 0;
        display: flex;
        flex-direction: column;
        gap: 2px;
      }
      .file-name {
        font-size: 13px;
        color: $deep-blue;
@@ -3546,6 +4033,12 @@
        font-weight: 600;
      }
      .file-size {
        font-size: 11px;
        color: #5f86b4;
        line-height: 1.1;
      }
      .remove-file {
        cursor: pointer;
        color: $secondary-blue;