2 天以前 c7ac44a1004dcf791fe7282368f1f657e035c961
src/components/AIChatSidebar/index.vue
@@ -1,7 +1,7 @@
<template>
  <div class="ai-chat-sidebar-wrapper">
    <!-- 悬浮图标 -->
    <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
    <div v-if="!hideTrigger" class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
      <el-tooltip :content="currentAssistant.tooltip" placement="left">
        <div class="trigger-icon">
          <el-icon :size="30" color="#fff"><component :is="currentAssistant.icon" /></el-icon>
@@ -12,14 +12,16 @@
    <!-- 侧边栏对话框 -->
    <el-drawer
        v-model="visible"
        :size="drawerSize"
        direction="rtl"
        :size="computedDrawerSize"
        :direction="drawerDirection"
        :with-header="true"
        class="ai-chat-drawer"
        :modal="false"
        modal-class="ai-chat-overlay"
        :show-close="false"
        :append-to-body="false"
        :close-on-press-escape="!hideTrigger"
        :close-on-click-modal="!hideTrigger"
        @close="handleClose"
    >
      <template #header>
@@ -50,8 +52,16 @@
                <el-icon :size="18"><Plus /></el-icon>
              </el-button>
            </el-tooltip>
            <div class="action-divider"></div>
            <el-tooltip content="关闭" placement="bottom">
            <el-button
                v-if="headerExtraActionText"
                link
                class="header-action-btn header-action-btn--text"
                @click="handleHeaderExtraAction"
            >
              {{ headerExtraActionText }}
            </el-button>
            <div v-if="!hideTrigger" class="action-divider"></div>
            <el-tooltip v-if="!hideTrigger" content="关闭" placement="bottom">
              <el-button link class="header-action-btn close-btn" @click="handleManualClose">
                <el-icon :size="18"><Close /></el-icon>
              </el-button>
@@ -105,23 +115,17 @@
              <div class="assistant-scan-ring"></div>
              <div class="assistant-orbit assistant-orbit-a"></div>
              <div class="assistant-orbit assistant-orbit-b"></div>
              <div class="assistant-bot">
                <div class="assistant-bot-antenna assistant-bot-antenna-left"></div>
                <div class="assistant-bot-antenna assistant-bot-antenna-right"></div>
                <div class="assistant-bot-head">
                  <div class="assistant-bot-head-glow"></div>
                  <div class="assistant-bot-eye assistant-bot-eye-left"></div>
                  <div class="assistant-bot-eye assistant-bot-eye-right"></div>
                  <div class="assistant-bot-mouth"></div>
                </div>
                <div class="assistant-bot-neck"></div>
                <div class="assistant-bot-body">
                  <div class="assistant-bot-core">
                    <div class="assistant-bot-core-ring"></div>
                    <el-icon :size="22"><component :is="currentAssistant.icon" /></el-icon>
              <div class="assistant-model-shell">
                <div class="assistant-model-cut">
                  <img
                      v-if="currentAssistantAvatar"
                      class="assistant-model-img"
                      :src="currentAssistantAvatar"
                      :alt="currentAssistant.label"
                  />
                  <div v-else class="assistant-model-fallback">
                    <el-icon :size="30"><component :is="currentAssistant.icon" /></el-icon>
                  </div>
                  <div class="assistant-bot-arm assistant-bot-arm-left"></div>
                  <div class="assistant-bot-arm assistant-bot-arm-right"></div>
                </div>
              </div>
              <div class="assistant-status">
@@ -169,10 +173,6 @@
            </div>
          </div>
          <div v-show="!hasMessages" class="hero-dot-grid" aria-hidden="true">
            <span v-for="dot in 28" :key="dot"></span>
          </div>
          <div class="message-list" ref="messageListRef">
            <div
                v-for="(message, index) in messages"
@@ -186,6 +186,37 @@
              <div class="message-content">
                <!-- 文本内容 -->
                <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">
@@ -212,6 +243,202 @@
                </div>
                <!-- 打字中动画 -->
                <div v-if="message.purchaseAnalysisData" class="purchase-confirm-card">
                  <div class="purchase-confirm-header">
                    <span>{{ businessTypeLabelMap[message.purchaseAnalysisData.businessType] || message.purchaseAnalysisData.businessType || '采购业务' }}</span>
                    <el-tag size="small" type="success" v-if="message.purchaseAnalysisData.confidence !== undefined">
                      置信度 {{ formatPercent(message.purchaseAnalysisData.confidence) }}
                    </el-tag>
                  </div>
                  <div class="purchase-confirm-desc">
                    {{ getPurchaseConfirmDescription(message.purchaseAnalysisData) }}
                  </div>
                  <div v-if="isPurchasePayloadEmpty(message.purchaseAnalysisData.payload)" class="purchase-empty-state">
                    <div class="empty-title">没有识别到可直接提交的采购台账信息</div>
                    <div class="empty-desc">当前文件里缺少采购合同号、供应商、项目、日期、物料明细等关键内容。请上传更完整的合同、订单或明细表,或在下方补充数据后再确认。</div>
                  </div>
                  <div v-if="message.purchaseAnalysisData.warnings?.length" class="purchase-alert warning">
                    <strong>风险提示</strong>
                    <ul>
                      <li v-for="(warning, warningIndex) in message.purchaseAnalysisData.warnings" :key="warningIndex">
                        {{ formatPreviewItem(warning) }}
                      </li>
                    </ul>
                  </div>
                  <div v-if="getVisiblePurchaseMissingFields(message.purchaseAnalysisData).length" class="purchase-alert missing">
                    <strong>需要补充 {{ getVisiblePurchaseMissingFields(message.purchaseAnalysisData).length }} 项</strong>
                    <el-tag
                        v-for="field in getVisiblePurchaseMissingFields(message.purchaseAnalysisData)"
                        :key="field"
                        size="small"
                        type="danger"
                    >
                      {{ field }}
                    </el-tag>
                  </div>
                  <div v-if="message.purchaseAnalysisData.preview?.length" class="purchase-preview">
                    <div class="purchase-section-title">确认摘要</div>
                    <ul>
                      <li v-for="(item, previewIndex) in message.purchaseAnalysisData.preview" :key="previewIndex">
                        {{ formatPreviewItem(item) }}
                      </li>
                    </ul>
                  </div>
                  <div class="purchase-section-title">补充或确认数据</div>
                  <div class="payload-toolbar">
                    <el-button
                        size="small"
                        plain
                        :disabled="message.confirming || message.confirmed"
                        @click="addPurchaseRootField(message)"
                    >
                      <el-icon><Plus /></el-icon>
                      新增顶层字段
                    </el-button>
                  </div>
                  <div class="payload-tree-table-wrapper">
                    <el-table
                        :data="message.payloadTreeData || []"
                        row-key="id"
                        border
                        stripe
                        size="small"
                        default-expand-all
                        :tree-props="{ children: 'children' }"
                        empty-text="暂无待确认数据"
                    >
                      <el-table-column label="字段" min-width="240">
                        <template #default="{ row }">
                          <div class="payload-key-cell">
                            <template v-if="row.parentType === 'object'">
                              <el-input
                                  v-if="row.keyEditable"
                                  v-model="row.key"
                                  size="small"
                                  :disabled="message.confirming || message.confirmed"
                                  placeholder="字段名"
                              />
                              <div v-else class="payload-fixed-key" :title="row.key">
                                <span>{{ getPurchaseFieldLabel(row.key) }}</span>
                                <small v-if="getPurchaseFieldLabel(row.key) !== row.key">{{ row.key }}</small>
                              </div>
                            </template>
                            <span v-else class="payload-array-index">{{ getPurchaseArrayItemLabel(row, message) }}</span>
                          </div>
                        </template>
                      </el-table-column>
                      <el-table-column label="类型" width="130" align="center">
                        <template #default="{ row }">
                          <el-select
                              v-model="row.valueType"
                              size="small"
                              :disabled="message.confirming || message.confirmed"
                              @change="handlePurchaseNodeTypeChange(message, row)"
                          >
                            <el-option
                                v-for="option in purchaseValueTypeOptions"
                                :key="option.value"
                                :label="option.label"
                                :value="option.value"
                            />
                          </el-select>
                        </template>
                      </el-table-column>
                      <el-table-column label="值" min-width="250">
                        <template #default="{ row }">
                          <div v-if="row.valueType === 'object'" class="payload-container-cell">
                            对象({{ row.children?.length || 0 }})
                          </div>
                          <div v-else-if="row.valueType === 'array'" class="payload-container-cell">
                            数组({{ row.children?.length || 0 }})
                          </div>
                          <el-switch
                              v-else-if="row.valueType === 'boolean'"
                              v-model="row.value"
                              size="small"
                              :disabled="message.confirming || message.confirmed"
                          />
                          <span v-else-if="row.valueType === 'null'" class="payload-null-value">null</span>
                          <el-input
                              v-else
                              v-model="row.value"
                              size="small"
                              :placeholder="row.valueType === 'number' ? '请输入数字' : '请输入内容'"
                              :disabled="message.confirming || message.confirmed"
                          />
                        </template>
                      </el-table-column>
                      <el-table-column label="操作" width="180" align="center">
                        <template #default="{ row }">
                          <div class="payload-row-actions">
                            <el-tooltip v-if="row.valueType === 'object'" content="新增字段" placement="top">
                              <el-button
                                  :icon="Plus"
                                  circle
                                  size="small"
                                  text
                                  type="primary"
                                  :disabled="message.confirming || message.confirmed"
                                  @click="addPurchaseChildNode(message, row)"
                              />
                            </el-tooltip>
                            <el-tooltip v-else-if="row.valueType === 'array'" content="新增数组项" placement="top">
                              <el-button
                                  :icon="Plus"
                                  circle
                                  size="small"
                                  text
                                  type="primary"
                                  :disabled="message.confirming || message.confirmed"
                                  @click="addPurchaseChildNode(message, row)"
                              />
                            </el-tooltip>
                            <el-tooltip v-if="row.parentType === 'array'" content="新增同级项" placement="top">
                              <el-button
                                  :icon="Plus"
                                  circle
                                  size="small"
                                  text
                                  type="primary"
                                  :disabled="message.confirming || message.confirmed"
                                  @click="addPurchaseSiblingNode(message, row)"
                              />
                            </el-tooltip>
                            <el-tooltip content="删除当前项" placement="top">
                              <el-button
                                  :icon="Delete"
                                  circle
                                  size="small"
                                  text
                                  type="danger"
                                  :disabled="message.confirming || message.confirmed"
                                  @click="removePurchaseNode(message, row)"
                              />
                            </el-tooltip>
                          </div>
                        </template>
                      </el-table-column>
                    </el-table>
                  </div>
                  <div class="payload-editor-tip">
                    日期请填写 yyyy-MM-dd,例如 2026-04-30。产品明细建议放在每条采购台账的 productData 中,确认时会自动兼容旧格式并清理审批字段。
                  </div>
                  <div class="purchase-confirm-actions">
                    <span v-if="message.confirmResult" :class="['confirm-result', message.confirmed ? 'success' : 'error']">
                      {{ message.confirmResult }}
                    </span>
                    <el-button
                        type="primary"
                        size="small"
                        :loading="message.confirming"
                        :disabled="message.confirmed || isSending"
                        @click="confirmPurchaseAnalysisFromTable(message)"
                    >
                      确认并执行
                    </el-button>
                  </div>
                </div>
                <div v-if="message.isTyping" class="typing-indicator">
                  <span class="dot"></span>
                  <span class="dot"></span>
@@ -235,6 +462,8 @@
                  action="#"
                  :auto-upload="false"
                  :show-file-list="false"
                  v-model:file-list="uploadFileList"
                  :multiple="currentAssistant.allowMultipleFileUpload"
                  :on-change="handleFileChange"
                  :disabled="isSending"
              >
@@ -244,15 +473,30 @@
              </el-upload>
            </div>
            <div class="input-box">
              <div v-if="selectedFile" class="selected-file-tag">
                <el-icon><Document /></el-icon>
                <span class="file-name">{{ selectedFile.name }}</span>
                <el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon>
              <div v-if="selectedFiles.length" class="selected-file-list">
                <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>
              <el-input
                  v-model="inputMessage"
                  type="textarea"
                  :rows="selectedFile ? 2 : 3"
                  :rows="selectedFiles.length ? 2 : 3"
                  :placeholder="currentAssistant.placeholder"
                  resize="none"
                  @keydown.enter.exact.prevent="sendMessage"
@@ -260,7 +504,7 @@
              <el-button
                  type="primary"
                  class="send-btn"
                  :disabled="isSending || (!inputMessage.trim() && !selectedFile)"
                  :disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)"
                  @click="sendMessage"
                  aria-label="发送"
              >
@@ -278,8 +522,17 @@
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'
import todoAssistantAvatar from '@/assets/AI/待办助手.png'
import salesAssistantAvatar from '@/assets/AI/销售助手.png'
import purchaseAssistantAvatar from '@/assets/AI/采购助手.png'
import productionAssistantAvatar from '@/assets/AI/生产助手.png'
import financeAssistantAvatar from '@/assets/AI/财务助手.png'
import bossAssistantAvatar from '@/assets/AI/待办助手.png'
const emit = defineEmits(['header-extra-action'])
const props = defineProps({
  assistants: {
@@ -289,70 +542,51 @@
  defaultAssistant: {
    type: String,
    default: ''
  },
  hideTrigger: {
    type: Boolean,
    default: false
  },
  autoOpen: {
    type: Boolean,
    default: false
  },
  drawerSize: {
    type: [String, Number],
    default: ''
  },
  drawerDirection: {
    type: String,
    default: 'rtl'
  },
  headerExtraActionText: {
    type: String,
    default: ''
  }
})
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: false,
    emptySessionText: '暂无采购会话'
  }
]
const hideTrigger = computed(() => props.hideTrigger)
const headerExtraActionText = computed(() => String(props.headerExtraActionText || '').trim())
const drawerDirection = computed(() => (props.drawerDirection === 'ttb' || props.drawerDirection === 'btt' || props.drawerDirection === 'ltr' || props.drawerDirection === 'rtl')
  ? props.drawerDirection
  : 'rtl')
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 assistantAvatarByKey = {
  general: todoAssistantAvatar,
  todo: todoAssistantAvatar,
  purchase: purchaseAssistantAvatar,
  sales: salesAssistantAvatar,
  production: productionAssistantAvatar,
  finance: financeAssistantAvatar,
  boss: bossAssistantAvatar
}
const currentAssistantAvatar = computed(() => {
  const assistant = currentAssistant.value || {}
  return assistant.avatar || assistantAvatarByKey[assistant.key] || ''
})
const showAssistantSwitch = computed(() => assistants.value.length > 1)
const quickPromptLimit = 3
const quickPromptStart = ref(0)
const quickPrompts = computed(() => {
@@ -360,7 +594,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 || []
@@ -376,26 +610,357 @@
const visible = ref(false)
const windowWidth = ref(window.innerWidth)
const drawerSize = computed(() => {
const responsiveDrawerSize = computed(() => {
  if (windowWidth.value < 768) return '100%'
  if (windowWidth.value < 1200) return '500px'
  return '600px'
  if (windowWidth.value < 1200) return '50%'
  return '50%'
})
const computedDrawerSize = computed(() => props.drawerSize || responsiveDrawerSize.value)
const messageListRef = ref(null)
const isSending = ref(false)
const currentAbortController = ref(null)
const inputMessage = ref('')
const selectedFile = ref(null)
const selectedFiles = ref([])
const uploadFileList = ref([])
const selectedFileSnapshots = ref([])
const messages = ref([])
const uuid = ref('')
const chartInstances = ref({})
const resizeHandlers = ref([])
const outputState = ref({})
const businessTypeLabelMap = {
  purchase_ledger: '采购台账',
  payment_registration: '付款登记',
  purchase_return_order: '采购退货单',
  unknown: '未知采购业务'
}
const purchasePayloadFieldLabelMap = {
  purchaseLedgers: '采购台账',
  productData: '产品明细',
  purchaseContractNumber: '采购合同号',
  purchaseContractNo: '采购合同号',
  purchaseOrderNumber: '采购合同号',
  salesContractNo: '销售合同号',
  salesContractNumber: '销售合同号',
  salesOrderNumber: '销售合同号',
  salesContractNoId: '销售合同ID',
  approveUserIds: '审批用户ID列表',
  entryDateStart: '录入开始日期',
  entryDateEnd: '录入结束日期',
  id: 'ID',
  supplierId: '供应商ID',
  projectName: '项目名称',
  supplierName: '供应商名称',
  isWhite: '是否白名单',
  recorderId: '录入人ID',
  recorderName: '录入人',
  contractDate: '执行日期',
  executionDate: '执行日期',
  inputPerson: '录入人',
  inputDate: '录入日期',
  entryDate: '录入日期',
  paymentMethod: '付款方式',
  auditors: '审批人',
  approverId: '审批人ID',
  approvalStatus: '审批状态',
  remark: '备注',
  remarks: '备注',
  attachmentMaterials: '附件材料',
  createdAt: '创建时间',
  updatedAt: '更新时间',
  salesLedgerId: '销售台账ID',
  hasChildren: '是否有子项',
  Type: '类型',
  type: '类型',
  tempFileIds: '临时文件ID',
  SalesLedgerFiles: '销售台账附件',
  phoneNumber: '联系电话',
  businessPersonId: '业务员ID',
  productId: '产品ID',
  productModelId: '产品型号ID',
  invoiceNumber: '发票号码',
  invoiceAmount: '发票金额',
  ticketRegistrationId: '开票登记ID',
  contractAmount: '合同金额',
  receiptPaymentAmount: '已收付款金额',
  unReceiptPaymentAmount: '未收付款金额',
  templateName: '模板名称',
  productCategory: '产品类别',
  specificationModel: '规格型号',
  unit: '单位',
  taxRate: '税率',
  taxInclusiveUnitPrice: '含税单价',
  priceWithTax: '含税单价',
  quantity: '数量',
  taxInclusiveTotalPrice: '含税总价',
  totalPriceWithTax: '含税总价',
  invoiceType: '发票类型',
  inventoryWarningQuantity: '库存预警数量',
  isInspected: '是否质检',
  isChecked: '是否质检'
}
const purchasePayloadFieldKeyMap = {
  采购台账: 'purchaseLedgers',
  产品明细: 'productData',
  采购合同号: 'purchaseContractNumber',
  采购单号: 'purchaseContractNumber',
  采购订单号: 'purchaseContractNumber',
  销售合同号: 'salesContractNo',
  销售单号: 'salesContractNo',
  销售订单号: 'salesContractNo',
  销售合同ID: 'salesContractNoId',
  审批用户ID列表: 'approveUserIds',
  录入开始日期: 'entryDateStart',
  录入结束日期: 'entryDateEnd',
  ID: 'id',
  项目名称: 'projectName',
  供应商ID: 'supplierId',
  供应商名称: 'supplierName',
  是否白名单: 'isWhite',
  录入人ID: 'recorderId',
  录入人: 'recorderName',
  签订日期: 'executionDate',
  执行日期: 'executionDate',
  录入日期: 'entryDate',
  付款方式: 'paymentMethod',
  审核人: 'approverId',
  审批人: 'approverId',
  审批人ID: 'approverId',
  审批状态: 'approvalStatus',
  备注: 'remarks',
  附件材料: 'attachmentMaterials',
  创建时间: 'createdAt',
  更新时间: 'updatedAt',
  销售台账ID: 'salesLedgerId',
  是否有子项: 'hasChildren',
  类型: 'type',
  临时文件ID: 'tempFileIds',
  销售台账附件: 'SalesLedgerFiles',
  联系电话: 'phoneNumber',
  业务员ID: 'businessPersonId',
  产品ID: 'productId',
  产品型号ID: 'productModelId',
  发票号码: 'invoiceNumber',
  发票金额: 'invoiceAmount',
  开票登记ID: 'ticketRegistrationId',
  合同金额: 'contractAmount',
  已收付款金额: 'receiptPaymentAmount',
  未收付款金额: 'unReceiptPaymentAmount',
  模板名称: 'templateName',
  产品类别: 'productCategory',
  产品名称: 'productCategory',
  规格型号: 'specificationModel',
  单位: 'unit',
  税率: 'taxRate',
  含税单价: 'taxInclusiveUnitPrice',
  数量: 'quantity',
  含税总价: 'taxInclusiveTotalPrice',
  发票类型: 'invoiceType',
  库存预警数量: 'inventoryWarningQuantity',
  是否质检: 'isInspected',
  purchaseLedgers: 'purchaseLedgers',
  productData: 'productData',
  purchaseContractNumber: 'purchaseContractNumber',
  purchaseContractNo: 'purchaseContractNumber',
  purchaseOrderNumber: 'purchaseContractNumber',
  salesContractNo: 'salesContractNo',
  salesContractNumber: 'salesContractNo',
  salesOrderNumber: 'salesContractNo',
  contractDate: 'executionDate',
  inputPerson: 'recorderName',
  inputDate: 'entryDate',
  auditors: 'approverId',
  remark: 'remarks',
  productCategory: 'productCategory',
  productName: 'productCategory',
  specificationModel: 'specificationModel',
  unit: 'unit',
  taxRate: 'taxRate',
  priceWithTax: 'taxInclusiveUnitPrice',
  taxInclusiveUnitPrice: 'taxInclusiveUnitPrice',
  quantity: 'quantity',
  totalPriceWithTax: 'taxInclusiveTotalPrice',
  taxInclusiveTotalPrice: 'taxInclusiveTotalPrice',
  invoiceType: 'invoiceType',
  inventoryWarningQuantity: 'inventoryWarningQuantity',
  isInspected: 'isInspected',
  isChecked: 'isInspected'
}
// 历史会话相关
const purchaseValueTypeOptions = [
  { label: '文本', value: 'string' },
  { label: '数字', value: 'number' },
  { label: '布尔', value: 'boolean' },
  { label: '空值', value: 'null' },
  { label: '对象', value: 'object' },
  { label: '数组', value: 'array' }
]
const purchaseContainerValueTypes = new Set(['object', 'array'])
const purchaseHiddenFieldKeySet = new Set(['templatename', 'approvalstatus', 'phonenumber', 'type'])
const purchaseHiddenKeyWordList = [
  'attachment',
  'file',
  'invoice',
  'ticketregistration',
  'receiptpayment',
  'payment'
]
const purchaseHiddenChineseKeywordList = ['附件', '开票', '来票', '回款', '付款']
let purchasePayloadTreeNodeSeed = 0
const shouldHidePurchaseField = (fieldKey = '') => {
  const rawKey = String(fieldKey || '')
  if (!rawKey) return false
  const normalizedFieldKey = purchasePayloadFieldKeyMap[rawKey] || rawKey
  const lowerKey = String(normalizedFieldKey).toLowerCase()
  if (lowerKey.endsWith('id') || lowerKey.endsWith('ids')) return true
  if (purchaseHiddenFieldKeySet.has(lowerKey)) return true
  if (purchaseHiddenKeyWordList.some(keyword => lowerKey.includes(keyword))) return true
  if (purchaseHiddenChineseKeywordList.some(keyword => rawKey.includes(keyword))) return true
  return false
}
const showHistory = ref(false)
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
@@ -440,6 +1005,7 @@
const selectSession = async (session) => {
  showHistory.value = false
  clearSelectedFiles()
  uuid.value = session.memoryId
  localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
@@ -447,6 +1013,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 || []
@@ -458,13 +1025,16 @@
        const messageObj = {
          isUser,
          content: msg.content,
          content: msg.content || '',
          htmlContent: '',
          isTyping: false,
          chartOptions: null,
          chartRenderReady: false,
          type: '',
          tableData: null
          tableData: null,
          payloadTreeData: null,
          payloadHiddenData: null,
          localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : []
        }
        messages.value.push(messageObj)
@@ -479,15 +1049,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()
@@ -522,15 +1092,16 @@
}
onMounted(() => {
  initUUID()
  // 初始欢迎
  if (messages.value.length === 0) {
    hello()
  if (props.autoOpen) {
    visible.value = true
  }
  initUUID()
  window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
  revokeMessageLocalFileSnapshots(messages.value)
  clearSelectedFiles()
  disposeCharts()
  window.removeEventListener('resize', handleWindowResize)
})
@@ -539,20 +1110,36 @@
  if (!prevKey || nextKey === prevKey) return
  abortCurrentRequest()
  revokeMessageLocalFileSnapshots(messages.value)
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFile.value = null
  clearSelectedFiles()
  inputMessage.value = ''
  quickPromptStart.value = 0
  initUUID()
  hello()
})
watch(() => props.defaultAssistant, (nextKey) => {
  if (!nextKey || nextKey === selectedAssistantKey.value) return
  if (!assistants.value.some(item => item.key === nextKey)) return
  selectedAssistantKey.value = nextKey
})
watch(() => props.autoOpen, (nextValue) => {
  if (nextValue) {
    visible.value = true
  }
})
const handleWindowResize = () => {
  windowWidth.value = window.innerWidth
}
const handleHeaderExtraAction = () => {
  emit('header-extra-action')
}
const toggleSidebar = () => {
@@ -563,10 +1150,12 @@
}
const handleClose = () => {
  if (hideTrigger.value) return
  visible.value = false
}
const handleManualClose = () => {
  if (hideTrigger.value) return
  if (isSending.value) {
    abortCurrentRequest()
  }
@@ -582,21 +1171,17 @@
  uuid.value = storedUUID
}
const hello = () => {
  sendRequest(currentAssistant.value.welcomeMessage || '你好')
}
const newChat = () => {
  revokeMessageLocalFileSnapshots(messages.value)
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFile.value = null
  clearSelectedFiles()
  quickPromptStart.value = 0
  localStorage.removeItem(currentAssistant.value.storageKey)
  initUUID()
  hello()
}
const handleNewChat = () => {
@@ -628,8 +1213,10 @@
const extractEmbeddedSuccessJson = (text) => {
  if (!text || typeof text !== 'string') return null
  const startIdx = text.indexOf('{"success"')
  if (startIdx === -1) return null
  const startMatch = text.match(/\{\s*"success"\s*:/)
  if (!startMatch) return null
  const startIdx = startMatch.index ?? -1
  if (startIdx < 0) return null
  for (let i = startIdx; i < text.length; i++) {
    if (text[i] !== '{') continue
@@ -692,8 +1279,25 @@
    messageObj.tableData = parsedData.data
  }
  if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
    messageObj.chartOptions = parsedData.charts
  if (parsedData.action === 'confirm_required' && parsedData.businessType) {
    messageObj.type = 'purchase_analysis_confirm'
    messageObj.purchaseAnalysisData = parsedData
    if (!Array.isArray(messageObj.payloadTreeData) || !messageObj.payloadTreeData.length) {
      initializePurchasePayloadTree(messageObj, parsedData.payload || {})
    }
    if (!messageObj.payloadText) {
      const payloadFromTree = buildPurchasePayloadFromNodes(messageObj.payloadTreeData, 'object')
      const payloadWithHidden = mergePurchasePayloadWithHidden(payloadFromTree, messageObj.payloadHiddenData)
      messageObj.payloadText = JSON.stringify(localizePurchasePayload(payloadWithHidden), null, 2)
    }
    messageObj.confirmResult = ''
    messageObj.confirmed = false
    messageObj.confirming = false
  }
  const chartOptions = getStructuredChartOptions(parsedData)
  if (chartOptions && Object.keys(chartOptions).length > 0) {
    messageObj.chartOptions = chartOptions
    messageObj.chartRenderReady = true
    if (shouldRenderCharts) {
@@ -705,6 +1309,1080 @@
  }
}
const getStructuredChartOptions = (parsedData) => {
  if (!parsedData?.success) return null
  if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
    return parsedData.charts
  }
  if (parsedData.type === 'purchase_material_rank') {
    return buildPurchaseMaterialRankCharts(parsedData)
  }
  return null
}
const buildPurchaseMaterialRankCharts = (parsedData) => {
  const items = Array.isArray(parsedData?.data?.items) ? parsedData.data.items : []
  if (!items.length) return null
  const names = items.map(item => item.productCategory || '-')
  const amounts = items.map(item => Number(item.amount) || 0)
  return {
    purchaseMaterialAmountRank: {
      title: {
        text: '\u91c7\u8d2d\u7269\u6599\u91d1\u989d\u6392\u884c',
        left: 'center',
        textStyle: {
          fontSize: 14,
          fontWeight: 600,
          color: '#1a1a2e'
        }
      },
      tooltip: {
        trigger: 'axis',
        axisPointer: {
          type: 'shadow'
        },
        formatter(params) {
          const dataIndex = params?.[0]?.dataIndex ?? 0
          const item = items[dataIndex] || {}
          const amount = Number(item.amount) || 0
          const quantity = Number(item.quantity) || 0
          return [
            `${item.productCategory || '-'}`,
            `${params?.[0]?.marker || ''} \u91d1\u989d\uff1a${formatCurrency(amount)}`,
            `\u89c4\u683c\u578b\u53f7\uff1a${item.specificationModel || '-'}`,
            `\u6570\u91cf\uff1a${quantity}${item.unit || ''}`
          ].join('<br/>')
        }
      },
      grid: {
        left: '3%',
        right: '4%',
        bottom: names.some(name => String(name).length > 6) ? 72 : 48,
        top: 48,
        containLabel: true
      },
      xAxis: {
        type: 'category',
        data: names,
        axisLabel: {
          interval: 0,
          rotate: names.some(name => String(name).length > 6) ? 28 : 0,
          color: '#4b5563'
        }
      },
      yAxis: {
        type: 'value',
        name: '\u91d1\u989d(\u5143)',
        axisLabel: {
          color: '#4b5563',
          formatter: value => formatCompactNumber(value)
        }
      },
      series: [{
        name: '\u91c7\u8d2d\u91d1\u989d',
        type: 'bar',
        barMaxWidth: 36,
        data: amounts,
        itemStyle: {
          borderRadius: [6, 6, 0, 0],
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: '#2f7cf6' },
            { offset: 1, color: '#55d7ff' }
          ])
        },
        label: {
          show: true,
          position: 'top',
          color: '#1f2937',
          formatter: params => formatCompactNumber(params.value)
        }
      }]
    }
  }
}
const formatCurrency = (value) => {
  const amount = Number(value) || 0
  return `\u00a5${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`
}
const formatCompactNumber = (value) => {
  const amount = Number(value) || 0
  if (Math.abs(amount) >= 10000) {
    return `${(amount / 10000).toFixed(2).replace(/\.?0+$/, '')}\u4e07`
  }
  return amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const formatPercent = (value) => {
  const number = Number(value)
  if (Number.isNaN(number)) return '-'
  return `${Math.round(number * 100)}%`
}
const formatPreviewItem = (item) => {
  if (item === null || item === undefined) return '-'
  if (typeof item === 'string') return item
  try {
    return JSON.stringify(item)
  } catch (err) {
    return String(item)
  }
}
const hasMeaningfulPayloadValue = (value) => {
  if (value === null || value === undefined) return false
  if (typeof value === 'string') return value.trim() !== ''
  if (Array.isArray(value)) return value.some(item => hasMeaningfulPayloadValue(item))
  if (typeof value === 'object') return Object.values(value).some(item => hasMeaningfulPayloadValue(item))
  return true
}
const mergeMappedPayloadValue = (existingValue, incomingValue) => {
  if (existingValue === undefined) return incomingValue
  const existingHasValue = hasMeaningfulPayloadValue(existingValue)
  const incomingHasValue = hasMeaningfulPayloadValue(incomingValue)
  if (existingHasValue && !incomingHasValue) return existingValue
  if (!existingHasValue && incomingHasValue) return incomingValue
  if (
    existingValue &&
    incomingValue &&
    typeof existingValue === 'object' &&
    typeof incomingValue === 'object' &&
    !Array.isArray(existingValue) &&
    !Array.isArray(incomingValue)
  ) {
    return { ...existingValue, ...incomingValue }
  }
  return incomingValue
}
const mapPayloadKeys = (value, keyMap) => {
  if (Array.isArray(value)) {
    return value.map(item => mapPayloadKeys(item, keyMap))
  }
  if (value && typeof value === 'object') {
    return Object.entries(value).reduce((result, [key, item]) => {
      const mappedKey = keyMap[key] || key
      const mappedValue = mapPayloadKeys(item, keyMap)
      result[mappedKey] = mergeMappedPayloadValue(result[mappedKey], mappedValue)
      return result
    }, {})
  }
  return value
}
const clonePurchasePayloadValue = (value) => {
  if (Array.isArray(value)) {
    return value.map(item => clonePurchasePayloadValue(item))
  }
  if (value && typeof value === 'object') {
    return Object.entries(value).reduce((result, [key, item]) => {
      result[key] = clonePurchasePayloadValue(item)
      return result
    }, {})
  }
  return value
}
const splitPurchasePayloadByVisibility = (value) => {
  if (Array.isArray(value)) {
    const splitItems = value.map(item => splitPurchasePayloadByVisibility(item))
    const visible = splitItems.map(item => item.visible)
    const hidden = splitItems.map(item => item.hidden)
    return { visible, hidden }
  }
  if (value && typeof value === 'object') {
    const visible = {}
    const hidden = {}
    Object.entries(value).forEach(([key, item]) => {
      if (shouldHidePurchaseField(key)) {
        hidden[key] = clonePurchasePayloadValue(item)
        return
      }
      const child = splitPurchasePayloadByVisibility(item)
      visible[key] = child.visible
      if (hasMeaningfulPayloadValue(child.hidden)) {
        hidden[key] = child.hidden
      }
    })
    return { visible, hidden }
  }
  return { visible: value, hidden: undefined }
}
const mergePurchasePayloadWithHidden = (visibleValue, hiddenValue) => {
  if (hiddenValue === undefined || hiddenValue === null) return visibleValue
  if (visibleValue === undefined || visibleValue === null) return clonePurchasePayloadValue(hiddenValue)
  if (Array.isArray(visibleValue) && Array.isArray(hiddenValue)) {
    return visibleValue.map((item, index) => mergePurchasePayloadWithHidden(item, hiddenValue[index]))
  }
  if (
    visibleValue &&
    hiddenValue &&
    typeof visibleValue === 'object' &&
    typeof hiddenValue === 'object' &&
    !Array.isArray(visibleValue) &&
    !Array.isArray(hiddenValue)
  ) {
    const merged = { ...clonePurchasePayloadValue(hiddenValue) }
    Object.entries(visibleValue).forEach(([key, item]) => {
      merged[key] = mergePurchasePayloadWithHidden(item, merged[key])
    })
    return merged
  }
  return visibleValue
}
const localizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldLabelMap)
const normalizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldKeyMap)
const createPurchasePayloadNodeId = () => `purchase-node-${Date.now()}-${purchasePayloadTreeNodeSeed++}`
const detectPurchaseValueType = (value) => {
  if (Array.isArray(value)) return 'array'
  if (value === null) return 'null'
  const valueType = typeof value
  if (valueType === 'number') return 'number'
  if (valueType === 'boolean') return 'boolean'
  if (valueType === 'object') return 'object'
  return 'string'
}
const normalizePurchaseNodeValueForEdit = (value, valueType) => {
  if (valueType === 'number') return value === null || value === undefined ? '' : String(value)
  if (valueType === 'boolean') return Boolean(value)
  if (valueType === 'null') return ''
  return value === null || value === undefined ? '' : String(value)
}
const createPurchaseTreeNode = ({
  key = '',
  parentType = 'object',
  keyEditable = false,
  valueType = 'string',
  value = '',
  children = []
} = {}) => ({
  id: createPurchasePayloadNodeId(),
  key,
  parentType,
  keyEditable,
  valueType,
  value,
  children
})
const reorderPurchaseObjectEntries = (value) => {
  const entries = Object.entries(value || {})
  const productDataIndex = entries.findIndex(([key]) => key === 'productData')
  if (productDataIndex <= -1 || productDataIndex === entries.length - 1) {
    return entries
  }
  const [productDataEntry] = entries.splice(productDataIndex, 1)
  entries.push(productDataEntry)
  return entries
}
const buildPurchasePayloadTreeNodes = (value, parentType = 'object') => {
  if (Array.isArray(value)) {
    return value.map(item => {
      const itemType = detectPurchaseValueType(item)
      const node = createPurchaseTreeNode({
        key: '',
        parentType: 'array',
        keyEditable: false,
        valueType: itemType,
        value: normalizePurchaseNodeValueForEdit(item, itemType)
      })
      if (purchaseContainerValueTypes.has(itemType)) {
        node.children = buildPurchasePayloadTreeNodes(item, itemType)
      }
      return node
    })
  }
  if (value && typeof value === 'object') {
    return reorderPurchaseObjectEntries(value).map(([key, item]) => {
      const itemType = detectPurchaseValueType(item)
      const node = createPurchaseTreeNode({
        key,
        parentType,
        keyEditable: false,
        valueType: itemType,
        value: normalizePurchaseNodeValueForEdit(item, itemType)
      })
      if (purchaseContainerValueTypes.has(itemType)) {
        node.children = buildPurchasePayloadTreeNodes(item, itemType)
      }
      return node
    })
  }
  return []
}
const initializePurchasePayloadTree = (messageObj, payload = {}) => {
  const sourcePayload = payload && typeof payload === 'object' && !Array.isArray(payload)
    ? payload
    : {}
  const { visible, hidden } = splitPurchasePayloadByVisibility(sourcePayload)
  const visiblePayload = visible && typeof visible === 'object' && !Array.isArray(visible) ? visible : {}
  messageObj.payloadTreeData = buildPurchasePayloadTreeNodes(visiblePayload, 'object')
  messageObj.payloadHiddenData = hidden && typeof hidden === 'object' ? hidden : {}
}
const getPurchaseFieldLabel = (fieldKey) => purchasePayloadFieldLabelMap[fieldKey] || fieldKey || '字段'
const createPurchaseDefaultNode = (parentType = 'object') => createPurchaseTreeNode({
  key: parentType === 'object' ? 'newField' : '',
  parentType,
  keyEditable: parentType === 'object',
  valueType: 'string',
  value: ''
})
const getPurchaseScalarNodeValue = (node) => {
  if (node.valueType === 'null') return null
  if (node.valueType === 'boolean') return Boolean(node.value)
  if (node.valueType === 'number') {
    const text = String(node.value ?? '').trim()
    if (!text) return null
    const numberValue = Number(text)
    return Number.isFinite(numberValue) ? numberValue : text
  }
  return node.value === null || node.value === undefined ? '' : String(node.value)
}
const buildPurchasePayloadFromNodes = (nodes, parentType = 'object') => {
  if (!Array.isArray(nodes)) {
    return parentType === 'array' ? [] : {}
  }
  if (parentType === 'array') {
    return nodes.map(node => {
      if (purchaseContainerValueTypes.has(node.valueType)) {
        return buildPurchasePayloadFromNodes(node.children, node.valueType)
      }
      return getPurchaseScalarNodeValue(node)
    })
  }
  return nodes.reduce((result, node, index) => {
    const rawKey = String(node.key ?? '').trim()
    const key = rawKey || `field_${index + 1}`
    if (purchaseContainerValueTypes.has(node.valueType)) {
      result[key] = buildPurchasePayloadFromNodes(node.children, node.valueType)
    } else {
      result[key] = getPurchaseScalarNodeValue(node)
    }
    return result
  }, {})
}
const findPurchaseNodeLocation = (nodes, targetId, parentNode = null) => {
  if (!Array.isArray(nodes)) return null
  for (let index = 0; index < nodes.length; index++) {
    const node = nodes[index]
    if (node.id === targetId) {
      return {
        siblings: nodes,
        index,
        node,
        parentNode
      }
    }
    const next = findPurchaseNodeLocation(node.children, targetId, node)
    if (next) return next
  }
  return null
}
const getPurchaseArrayItemLabel = (row, message) => {
  const location = findPurchaseNodeLocation(message?.payloadTreeData, row.id)
  return `[${(location?.index ?? 0) + 1}]`
}
const handlePurchaseNodeTypeChange = (message, row) => {
  if (!message || !row) return
  if (purchaseContainerValueTypes.has(row.valueType)) {
    row.children = []
    row.value = ''
    return
  }
  row.children = []
  if (row.valueType === 'boolean') {
    row.value = false
  } else if (row.valueType === 'null') {
    row.value = ''
  } else {
    row.value = ''
  }
}
const addPurchaseRootField = (message) => {
  if (!message) return
  if (!Array.isArray(message.payloadTreeData)) {
    message.payloadTreeData = []
  }
  message.payloadTreeData.push(createPurchaseDefaultNode('object'))
}
const addPurchaseChildNode = (message, row) => {
  if (!message || !row || !purchaseContainerValueTypes.has(row.valueType)) return
  if (!Array.isArray(row.children)) {
    row.children = []
  }
  row.children.push(createPurchaseDefaultNode(row.valueType))
}
const addPurchaseSiblingNode = (message, row) => {
  if (!message || !row) return
  const location = findPurchaseNodeLocation(message.payloadTreeData, row.id)
  if (!location || location.node.parentType !== 'array') return
  location.siblings.splice(location.index + 1, 0, createPurchaseDefaultNode('array'))
}
const removePurchaseNode = (message, row) => {
  if (!message || !row) return
  const location = findPurchaseNodeLocation(message.payloadTreeData, row.id)
  if (!location) return
  location.siblings.splice(location.index, 1)
}
const hasPurchaseNodeValidationError = (nodes, parentType = 'object') => {
  if (!Array.isArray(nodes)) return false
  return nodes.some((node) => {
    if (parentType === 'object' && !String(node.key ?? '').trim()) {
      return true
    }
    if (node.valueType === 'number') {
      const text = String(node.value ?? '').trim()
      if (text && !Number.isFinite(Number(text))) {
        return true
      }
    }
    if (purchaseContainerValueTypes.has(node.valueType)) {
      return hasPurchaseNodeValidationError(node.children, node.valueType)
    }
    return false
  })
}
const purchaseDateFieldKeys = new Set([
  'entryDateStart',
  'entryDateEnd',
  'entryDate',
  'executionDate',
  'contractDate',
  'inputDate',
  'createdAt',
  'updatedAt'
])
const purchaseLedgerAllowedFieldKeys = new Set([
  'entryDateStart',
  'entryDateEnd',
  'id',
  'purchaseContractNumber',
  'supplierId',
  'supplierName',
  'isWhite',
  'recorderId',
  'recorderName',
  'salesContractNo',
  'salesContractNoId',
  'projectName',
  'entryDate',
  'executionDate',
  'remarks',
  'attachmentMaterials',
  'createdAt',
  'updatedAt',
  'salesLedgerId',
  'hasChildren',
  'Type',
  'productData',
  'tempFileIds',
  'SalesLedgerFiles',
  'phoneNumber',
  'businessPersonId',
  'productId',
  'productModelId',
  'invoiceNumber',
  'invoiceAmount',
  'ticketRegistrationId',
  'contractAmount',
  'receiptPaymentAmount',
  'unReceiptPaymentAmount',
  'type',
  'paymentMethod',
  'approvalStatus',
  'templateName'
])
const purchaseApprovalFieldKeys = new Set([
  'approveUserIds',
  'approverId',
  'auditors',
  '审核人',
  '审批人',
  '审批人ID',
  '审批用户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)
}
const getPurchaseProductMatchKey = (record) => {
  if (!record || typeof record !== 'object') return ''
  return record.purchaseContractNumber ||
    record.purchaseContractNo ||
    record.salesContractNo ||
    record.salesContractNumber ||
    ''
}
const prunePurchaseProductRecord = (record) => {
  if (!record || typeof record !== 'object' || Array.isArray(record)) return null
  const normalizedRecord = normalizePurchaseProductRecord(record)
  const hasVisibleFieldValue = Object.entries(normalizedRecord).some(([key, value]) => {
    if (shouldHidePurchaseField(key)) return false
    return hasMeaningfulPayloadValue(value)
  })
  return hasVisibleFieldValue ? normalizedRecord : null
}
const normalizeAndFilterPurchaseProductData = (value) => {
  if (!Array.isArray(value)) return value
  return value
    .map(item => prunePurchaseProductRecord(item))
    .filter(Boolean)
}
const mergeLegacyProductDataIntoLedgers = (payload) => {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
  if (!Array.isArray(payload.purchaseLedgers) || !Array.isArray(payload.productData) || !payload.productData.length) {
    return payload
  }
  const ledgers = payload.purchaseLedgers.map(ledger => ({
    ...ledger,
    productData: normalizeAndFilterPurchaseProductData(ledger.productData) || []
  }))
  const unmatchedProducts = []
  normalizeAndFilterPurchaseProductData(payload.productData).forEach(product => {
    const productMatchKey = getPurchaseProductMatchKey(product)
    const matchedLedger = ledgers.find(ledger => {
      const ledgerKeys = [
        ledger.purchaseContractNumber,
        ledger.purchaseContractNo,
        ledger.salesContractNo,
        ledger.salesContractNumber
      ].filter(Boolean)
      return productMatchKey && ledgerKeys.includes(productMatchKey)
    })
    if (matchedLedger) {
      matchedLedger.productData.push(product)
    } else if (ledgers.length === 1) {
      ledgers[0].productData.push(product)
    } else {
      unmatchedProducts.push(product)
    }
  })
  const nextPayload = {
    ...payload,
    purchaseLedgers: ledgers
  }
  if (unmatchedProducts.length) {
    nextPayload.productData = unmatchedProducts
  } else {
    delete nextPayload.productData
  }
  return nextPayload
}
const filterPurchaseLedgerRecord = (record) => {
  if (!record || typeof record !== 'object' || Array.isArray(record)) return record
  const normalizedRecord = {
    ...record,
    productData: normalizeAndFilterPurchaseProductData(record.productData)
  }
  return Object.entries(normalizedRecord).reduce((result, [key, value]) => {
    if (purchaseLedgerAllowedFieldKeys.has(key)) {
      result[key] = value
    }
    return result
  }, {})
}
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
  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)
  }
  if (Array.isArray(sanitized.productData)) {
    sanitized.productData = normalizeAndFilterPurchaseProductData(sanitized.productData)
  }
  if (Array.isArray(sanitized.productData) && !sanitized.productData.length) {
    delete sanitized.productData
  }
  purchaseApprovalFieldKeys.forEach(key => {
    if (!Array.isArray(sanitized)) {
      delete sanitized[key]
    }
  })
  return sanitized
}
const getVisiblePurchaseMissingFields = (analysisData) => {
  const fields = Array.isArray(analysisData?.missingFields) ? analysisData.missingFields : []
  const visibleFields = analysisData?.businessType === 'purchase_ledger'
    ? fields.filter(field => {
      if (purchaseApprovalFieldKeys.has(field)) return false
      const normalizedField = purchasePayloadFieldKeyMap[field] || field
      return !shouldHidePurchaseField(normalizedField) && !shouldHidePurchaseField(field)
    })
    : fields
  return visibleFields.map(field => purchasePayloadFieldLabelMap[field] || field)
}
const formatDateParts = (year, month, day) => {
  const normalizedYear = Number(year)
  const normalizedMonth = Number(month)
  const normalizedDay = Number(day)
  if (!normalizedYear || !normalizedMonth || !normalizedDay) return ''
  const date = new Date(normalizedYear, normalizedMonth - 1, normalizedDay)
  if (
    date.getFullYear() !== normalizedYear ||
    date.getMonth() !== normalizedMonth - 1 ||
    date.getDate() !== normalizedDay
  ) {
    return ''
  }
  return [
    String(normalizedYear).padStart(4, '0'),
    String(normalizedMonth).padStart(2, '0'),
    String(normalizedDay).padStart(2, '0')
  ].join('-')
}
const normalizeDateString = (value) => {
  if (typeof value !== 'string') return value
  const text = value.trim()
  if (!text) return value
  let match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s].*)?$/)
  if (match) return formatDateParts(match[1], match[2], match[3]) || value
  match = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})(?:\s.*)?$/)
  if (match) return formatDateParts(match[1], match[2], match[3]) || value
  match = text.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日?(?:\s.*)?$/)
  if (match) return formatDateParts(match[1], match[2], match[3]) || value
  match = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})(?:\s.*)?$/)
  if (match) {
    const year = match[3].length === 2 ? Number(`20${match[3]}`) : Number(match[3])
    return formatDateParts(year, match[1], match[2]) || value
  }
  return value
}
const normalizePurchasePayloadDates = (value, key = '') => {
  if (Array.isArray(value)) {
    return value.map(item => normalizePurchasePayloadDates(item, key))
  }
  if (value && typeof value === 'object') {
    return Object.entries(value).reduce((result, [itemKey, item]) => {
      result[itemKey] = normalizePurchasePayloadDates(item, itemKey)
      return result
    }, {})
  }
  return purchaseDateFieldKeys.has(key) ? normalizeDateString(value) : value
}
const isEmptyValue = (value) => {
  if (value === null || value === undefined || value === '') return true
  if (Array.isArray(value)) return value.every(item => isEmptyValue(item))
  if (typeof value === 'object') return Object.values(value).every(item => isEmptyValue(item))
  return false
}
const isPurchasePayloadEmpty = (payload) => isEmptyValue(payload)
const getPurchaseConfirmDescription = (analysisData) => {
  if (!analysisData) return ''
  if (isPurchasePayloadEmpty(analysisData.payload)) {
    return '我没有从文件中提取到完整的采购业务数据,暂时不能直接生成采购台账。'
  }
  return analysisData.description || '已整理出待确认的采购业务数据,请核对后提交。'
}
const confirmPurchaseAnalysis = async (message) => {
  if (!message?.purchaseAnalysisData || message.confirming || message.confirmed) return
  let payload
  try {
    const parsedPayload = message.payloadText?.trim() ? JSON.parse(message.payloadText) : {}
    payload = sanitizePurchasePayloadForSubmit(
      normalizePurchasePayloadDates(normalizePurchasePayload(parsedPayload)),
      message.purchaseAnalysisData.businessType
    )
  } catch (err) {
    message.confirmResult = '待提交数据不是合法 JSON,请修改后再确认'
    message.confirmed = false
    return
  }
  message.confirming = true
  message.confirmResult = ''
  try {
    const res = await request.post(`${currentAssistant.value.apiBase}/analyze-files/confirm`, {
      businessType: message.purchaseAnalysisData.businessType,
      payload
    })
    message.confirmed = true
    message.confirmResult = res?.msg || '确认成功,业务处理已提交'
    ElMessage.success(message.confirmResult)
  } catch (err) {
    message.confirmed = false
    message.confirmResult = err?.message || '确认失败,请检查数据后重试'
  } finally {
    message.confirming = false
  }
}
const confirmPurchaseAnalysisFromTable = async (message) => {
  if (!message?.purchaseAnalysisData || message.confirming || message.confirmed) return
  if (!Array.isArray(message.payloadTreeData)) {
    initializePurchasePayloadTree(message, message.purchaseAnalysisData.payload || {})
  }
  if (hasPurchaseNodeValidationError(message.payloadTreeData, 'object')) {
    message.confirmResult = '请先补全字段名,并确保数字字段填写合法数字'
    message.confirmed = false
    return
  }
  let payload
  try {
    const draftPayload = buildPurchasePayloadFromNodes(message.payloadTreeData, 'object')
    const mergedPayload = mergePurchasePayloadWithHidden(draftPayload, message.payloadHiddenData)
    const normalizedPayload = normalizePurchasePayload(mergedPayload)
    payload = sanitizePurchasePayloadForSubmit(
      normalizePurchasePayloadDates(normalizedPayload),
      message.purchaseAnalysisData.businessType
    )
    message.payloadText = JSON.stringify(localizePurchasePayload(normalizedPayload), null, 2)
  } catch (err) {
    message.confirmResult = '待提交数据格式有误,请检查后再确认'
    message.confirmed = false
    return
  }
  message.confirming = true
  message.confirmResult = ''
  try {
    const res = await request.post(`${currentAssistant.value.apiBase}/analyze-files/confirm`, {
      businessType: message.purchaseAnalysisData.businessType,
      payload
    })
    message.confirmed = true
    message.confirmResult = res?.msg || '确认成功,业务处理已提交'
    ElMessage.success(message.confirmResult)
  } catch (err) {
    message.confirmed = false
    message.confirmResult = err?.message || '确认失败,请检查数据后重试'
  } finally {
    message.confirming = false
  }
}
const scrollToBottom = () => {
  nextTick(() => {
    if (messageListRef.value) {
@@ -713,35 +2391,48 @@
  })
}
const handleFileChange = (file) => {
const handleFileChange = (file, fileList = []) => {
  if (!file) return
  const rawFile = file.raw
  if (rawFile) {
    // 限制文件大小,例如 10MB
  const nextFiles = currentAssistant.value.allowMultipleFileUpload
    ? fileList.map(item => item.raw).filter(Boolean)
    : [file.raw].filter(Boolean)
  const validFiles = nextFiles.filter(rawFile => {
    const isLt10M = rawFile.size / 1024 / 1024 < 10
    if (!isLt10M) {
      ElMessage.error('文件大小不能超过 10MB!')
      return
      ElMessage.error(`${rawFile.name} 文件大小不能超过 10MB!`)
    }
    selectedFile.value = rawFile
  }
    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 = () => {
  selectedFile.value = null
const removeSelectedFile = (index) => {
  const [removedSnapshot] = selectedFileSnapshots.value.splice(index, 1)
  revokeLocalFileSnapshots(removedSnapshot ? [removedSnapshot] : [])
  selectedFiles.value.splice(index, 1)
  uploadFileList.value.splice(index, 1)
}
const analyzeFile = async (file, message = '') => {
const analyzeFiles = async (files, message = '', localFileSnapshots = []) => {
  const uploadFiles = Array.isArray(files) ? files : [files].filter(Boolean)
  if (!uploadFiles.length) return
  if (isSending.value) return
  isSending.value = true
  currentAbortController.value = new AbortController()
  const userMsg = message ? `${message}\n[上传文件分析] ${file.name}` : `[上传文件分析] ${file.name}`
  const fileNames = uploadFiles.map(file => file.name).join('、')
  const userMsg = message ? `${message}\n[上传文件分析] ${fileNames}` : `[上传文件分析] ${fileNames}`
  messages.value.push({
    isUser: true,
    content: userMsg,
    htmlContent: convertTextToHtml(userMsg),
    isTyping: false
    isTyping: false,
    localUploadFiles: Array.isArray(localFileSnapshots) ? localFileSnapshots : []
  })
  const botMsgIndex = messages.value.length
@@ -753,7 +2444,9 @@
    chartOptions: null,
    chartRenderReady: false,
    type: '',
    tableData: null
    tableData: null,
    payloadTreeData: null,
    payloadHiddenData: null
  })
  outputState.value[botMsgIndex] = {
@@ -767,13 +2460,15 @@
  scrollToBottom()
  const formData = new FormData()
  formData.append('file', file)
  const fileFieldName = currentAssistant.value.allowMultipleFileUpload ? 'files' : 'file'
  uploadFiles.forEach(file => formData.append(fileFieldName, file))
  formData.append('memoryId', uuid.value)
  if (message.trim()) {
    formData.append('message', message.trim())
  }
  request.post(`${currentAssistant.value.apiBase}/analyze-file`, formData, {
  const analyzeUrl = currentAssistant.value.fileAnalyzeUrl || `${currentAssistant.value.apiBase}/analyze-file`
  request.post(analyzeUrl, formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    },
@@ -803,6 +2498,11 @@
    isSending.value = false
    currentAbortController.value = null
    const extracted = extractEmbeddedSuccessJson(currentMsg.content)
    if (extracted) {
      applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart)
    }
    // 最终解析确保图表渲染
    if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) {
      renderCharts(botMsgIndex, currentMsg.chartOptions)
@@ -811,6 +2511,8 @@
  }).catch(err => {
    if (err.name === 'CanceledError' || err.name === 'AbortError') {
      console.log('Analysis aborted by user')
      isSending.value = false
      currentAbortController.value = null
      return
    }
    console.error('File analysis error:', err)
@@ -827,10 +2529,11 @@
const sendMessage = () => {
  const msg = inputMessage.value?.trim() || ''
  if ((msg || selectedFile.value) && !isSending.value) {
    if (selectedFile.value) {
      analyzeFile(selectedFile.value, msg)
      selectedFile.value = null
  if ((msg || selectedFiles.value.length) && !isSending.value) {
    if (selectedFiles.value.length) {
      const localFileSnapshots = selectedFileSnapshots.value
      analyzeFiles([...selectedFiles.value], msg, localFileSnapshots)
      clearSelectedFiles({ releaseSnapshots: false })
    } else {
      sendRequest(msg)
    }
@@ -864,7 +2567,9 @@
    chartOptions: null,
    chartRenderReady: false,
    type: '',
    tableData: null
    tableData: null,
    payloadTreeData: null,
    payloadHiddenData: null
  }
  messages.value.push(botMsg)
@@ -915,16 +2620,7 @@
            const parsedData = extractJson(fullText)
            if (parsedData) {
              currentMsg.type = parsedData.type || ''
              if (currentMsg.type === 'todo_list' && parsedData.data) {
                currentMsg.tableData = parsedData.data
              }
              if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
                currentMsg.chartOptions = parsedData.charts
                currentMsg.chartRenderReady = true
                // 每次解析成功都尝试渲染/更新图表,以支持流式更新
                renderCharts(botMsgIndex, currentMsg.chartOptions)
              }
              applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true)
            }
          }
@@ -960,15 +2656,7 @@
      const finalParsed = extractJson(currentMsg.content)
      if (finalParsed) {
        currentMsg.type = finalParsed.type || ''
        if (currentMsg.type === 'todo_list' && finalParsed.data) {
          currentMsg.tableData = finalParsed.data
        }
        if (finalParsed.charts && Object.keys(finalParsed.charts).length > 0) {
          currentMsg.chartOptions = finalParsed.charts
          currentMsg.chartRenderReady = true
          renderCharts(botMsgIndex, currentMsg.chartOptions)
        }
        applyStructuredMessageData(currentMsg, finalParsed, botMsgIndex)
      }
    }
  }).catch(err => {
@@ -1021,7 +2709,8 @@
  // 尝试提取 JSON 部分
  const extracted = extractEmbeddedSuccessJson(output)
  const startIdx = extracted ? extracted.startIdx : output.indexOf('{"success"')
  const startMatch = output.match(/\{\s*"success"\s*:/)
  const startIdx = extracted ? extracted.startIdx : (startMatch?.index ?? -1)
  // 如果还在代码块中且未结束,显示提示文字
  if (state && ((state.jsonBlockStartPos !== -1) || (state.jsBlockStartPos !== -1)) && state.blockEndPos === -1) {
@@ -1040,7 +2729,9 @@
    }
    if (parsed.description) {
      display = parsed.description
      display = parsed.action === 'confirm_required'
        ? getPurchaseConfirmDescription(parsed)
        : parsed.description
    }
    if (!display) {
@@ -1066,7 +2757,9 @@
        }
        if (parsed.description) {
          display = parsed.description
          display = parsed.action === 'confirm_required'
            ? getPurchaseConfirmDescription(parsed)
            : parsed.description
        }
        if (!display) {
@@ -1422,8 +3115,9 @@
    height: 100%;
  }
  :deep(.el-drawer__header) {
    margin-bottom: 0;
    padding: 0;
    margin-bottom: 0 !important;
    padding: 0 !important;
    border-bottom: 1px solid rgba(255, 255, 255, 0.12);
    background: $gradient-dark;
    color: #fff;
  }
@@ -1434,7 +3128,7 @@
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 18px 20px;
  padding: 12px 18px;
  background: $gradient-dark;
  position: relative;
  overflow: hidden;
@@ -1566,6 +3260,15 @@
        opacity: 1;
      }
    }
    :deep(.header-action-btn--text) {
      width: auto !important;
      min-width: 104px;
      padding: 8px 14px !important;
      font-size: 14px;
      font-weight: 600;
      white-space: nowrap;
    }
  }
  .assistant-switcher {
@@ -1648,7 +3351,7 @@
    top: 0;
    left: 0;
    right: 0;
    height: 240px;
    height: 128px;
    background: linear-gradient(180deg, rgba(0, 85, 212, 0.06) 0%, transparent 100%);
    pointer-events: none;
  }
@@ -1888,6 +3591,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 {
@@ -2008,6 +3792,185 @@
  }
}
.purchase-confirm-card {
  margin-top: 12px;
  width: 100%;
  background: #fff;
  border: 1px solid rgba(0, 85, 212, 0.12);
  border-radius: 12px;
  box-shadow: $shadow-card;
  padding: 14px;
  color: #1a1a2e;
}
.purchase-confirm-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font-size: 15px;
  font-weight: 700;
  margin-bottom: 12px;
}
.purchase-confirm-desc {
  margin-bottom: 12px;
  color: #374151;
  font-size: 13px;
  line-height: 1.6;
}
.purchase-empty-state {
  margin-bottom: 12px;
  padding: 12px;
  border-radius: 10px;
  background: linear-gradient(135deg, rgba(255, 247, 237, 0.96), rgba(255, 255, 255, 0.98));
  border: 1px solid rgba(245, 158, 11, 0.25);
  .empty-title {
    font-size: 14px;
    font-weight: 700;
    color: #92400e;
    margin-bottom: 6px;
  }
  .empty-desc {
    color: #78350f;
    font-size: 13px;
    line-height: 1.6;
  }
}
.purchase-alert {
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 10px;
  font-size: 13px;
  ul {
    margin: 6px 0 0;
    padding-left: 18px;
  }
  &.warning {
    background: rgba(230, 162, 60, 0.12);
    color: #9a5b00;
  }
  &.missing {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    background: rgba(245, 108, 108, 0.1);
    color: #b42318;
  }
}
.purchase-preview {
  margin-bottom: 12px;
  ul {
    margin: 6px 0 0;
    padding-left: 18px;
    font-size: 13px;
    line-height: 1.7;
  }
}
.purchase-section-title {
  margin: 10px 0 6px;
  font-size: 13px;
  font-weight: 700;
  color: $deep-blue;
}
.payload-toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 8px;
}
.payload-tree-table-wrapper {
  border: 1px solid rgba(0, 85, 212, 0.1);
  border-radius: 10px;
  overflow: auto;
  :deep(.el-table) {
    --el-table-header-bg-color: #f5f8ff;
    --el-table-border-color: rgba(0, 85, 212, 0.08);
  }
}
.payload-key-cell {
  display: flex;
  align-items: center;
  min-height: 28px;
}
.payload-fixed-key {
  display: flex;
  flex-direction: column;
  gap: 2px;
  line-height: 1.3;
  color: #1f2937;
  small {
    font-size: 11px;
    color: #6b7280;
  }
}
.payload-array-index {
  font-size: 12px;
  color: #475467;
}
.payload-container-cell {
  color: #344054;
  font-size: 12px;
}
.payload-null-value {
  color: #6b7280;
  font-size: 12px;
}
.payload-row-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
}
.payload-editor-tip {
  margin-top: 6px;
  font-size: 12px;
  line-height: 1.5;
  color: #6b7280;
}
.purchase-confirm-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 12px;
  .confirm-result {
    flex: 1;
    font-size: 13px;
    &.success {
      color: #1f9d55;
    }
    &.error {
      color: #d93025;
    }
  }
}
.input-area {
  padding: 18px 20px;
  background: linear-gradient(180deg, rgba(232, 242, 255, 0.95) 0%, #fff 100%);
@@ -2086,6 +4049,13 @@
      background: #fff;
    }
    .selected-file-list {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-bottom: 12px;
    }
    .selected-file-tag {
      display: flex;
      align-items: center;
@@ -2093,7 +4063,6 @@
      border: 1px solid rgba(0, 85, 212, 0.2);
      border-radius: 10px;
      padding: 8px 12px;
      margin-bottom: 12px;
      gap: 10px;
      width: fit-content;
      max-width: 100%;
@@ -2115,6 +4084,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;
@@ -2122,6 +4113,12 @@
        overflow: hidden;
        text-overflow: ellipsis;
        font-weight: 600;
      }
      .file-size {
        font-size: 11px;
        color: #5f86b4;
        line-height: 1.1;
      }
      .remove-file {
@@ -2281,75 +4278,59 @@
.chat-hero {
  display: grid;
  grid-template-columns: 164px minmax(0, 1fr);
  gap: 18px;
  align-items: start;
  padding: 14px 18px 6px;
  grid-template-columns: 176px minmax(0, 1fr);
  gap: 14px;
  align-items: stretch;
  padding: 8px 18px 4px;
  &.compact {
    grid-template-columns: 122px minmax(0, 1fr);
    gap: 12px;
    padding: 8px 18px 2px;
    grid-template-columns: 132px minmax(0, 1fr);
    gap: 10px;
    padding: 4px 18px 2px;
  }
}
.assistant-stand {
  position: relative;
  min-height: 252px;
  min-height: 206px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  padding-top: 18px;
  padding-top: 8px;
  overflow: hidden;
  &.compact {
    min-height: 176px;
    padding-top: 8px;
    min-height: 160px;
    padding-top: 4px;
  }
  &.thinking {
    .assistant-halo {
      opacity: 1;
      transform: scale(1.08);
      filter: blur(8px);
      transform: scale(1.12);
      filter: blur(9px);
    }
    .assistant-scan-ring {
      opacity: 1;
      animation-duration: 1.6s;
      opacity: 0.95;
      animation-duration: 1.5s;
    }
    .assistant-orbit {
      opacity: 1;
      opacity: 0.76;
    }
    .assistant-bot {
      transform: translateY(-4px) scale(1.02);
    .assistant-model-shell {
      transform: translateY(-5px) scale(1.02);
    }
    .assistant-bot-head {
      box-shadow: 0 0 30px rgba(80, 157, 255, 0.36);
    .assistant-model-cut {
      animation-duration: 2.2s;
    }
    .assistant-bot-eye {
      animation: robotBlinkFast 1.1s infinite;
      box-shadow: 0 0 16px rgba(72, 186, 255, 0.95);
    }
    .assistant-bot-mouth {
      width: 28px;
      opacity: 1;
      animation: robotTalk 1.2s ease-in-out infinite;
    }
    .assistant-bot-core {
      animation: corePulse 1.4s ease-in-out infinite;
      box-shadow: 0 0 24px rgba(78, 120, 255, 0.26);
    }
    .assistant-bot-core-ring {
      animation: coreRotate 3s linear infinite;
    .assistant-model-img {
      filter: saturate(1.06) drop-shadow(0 18px 20px rgba(22, 48, 80, 0.22));
    }
    .assistant-status {
@@ -2363,219 +4344,174 @@
      animation: thinkingDot 1s ease-in-out infinite;
    }
    .assistant-base-lg {
      animation-duration: 1.8s;
    }
    .assistant-base-md {
      animation-duration: 1.5s;
    }
    .assistant-base-sm {
      box-shadow: 0 0 24px rgba(255, 93, 122, 0.48);
      box-shadow: 0 0 24px rgba(30, 91, 255, 0.36);
      animation-duration: 1.25s;
    }
  }
}
.assistant-halo {
  position: absolute;
  top: 22px;
  width: 130px;
  height: 130px;
  top: 24px;
  width: 146px;
  height: 146px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(46, 140, 224, 0.3) 0%, rgba(0, 85, 212, 0.18) 42%, rgba(113, 54, 244, 0.12) 60%, transparent 78%);
  background: radial-gradient(circle, rgba(31, 122, 114, 0.26) 0%, rgba(30, 91, 255, 0.2) 42%, rgba(109, 65, 237, 0.12) 66%, transparent 80%);
  filter: blur(6px);
  opacity: 0.82;
  opacity: 0.78;
  transition: all 0.35s ease;
}
.assistant-scan-ring {
  position: absolute;
  top: 40px;
  width: 132px;
  height: 132px;
  top: 44px;
  width: 136px;
  height: 136px;
  border-radius: 50%;
  border: 1px solid rgba(90, 159, 224, 0.22);
  border: 1px solid rgba(67, 145, 223, 0.24);
  box-shadow: inset 0 0 16px rgba(255, 255, 255, 0.25);
  opacity: 0.55;
  opacity: 0.52;
  animation: scanRing 4s linear infinite;
}
.assistant-orbit {
  position: absolute;
  top: 52px;
  width: 150px;
  height: 150px;
  width: 156px;
  height: 156px;
  border-radius: 50%;
  border: 1px dashed rgba(92, 135, 255, 0.22);
  opacity: 0.45;
  border: 1px dashed rgba(92, 135, 255, 0.24);
  opacity: 0.42;
}
.assistant-orbit-a {
  animation: orbitRotate 8s linear infinite;
  animation: orbitRotate 8.6s linear infinite;
}
.assistant-orbit-b {
  width: 118px;
  height: 118px;
  width: 124px;
  height: 124px;
  top: 68px;
  border-color: rgba(255, 108, 150, 0.22);
  animation: orbitRotateReverse 5.6s linear infinite;
  border-color: rgba(31, 122, 114, 0.24);
  animation: orbitRotateReverse 6.2s linear infinite;
}
.assistant-bot {
.assistant-model-shell {
  position: relative;
  z-index: 1;
  width: 148px;
  height: 178px;
  display: flex;
  flex-direction: column;
  align-items: center;
  align-items: flex-end;
  justify-content: center;
  margin-top: 12px;
  margin-top: 4px;
  transition: transform 0.35s ease;
}
.assistant-bot-antenna {
  position: absolute;
  top: -4px;
  width: 4px;
  height: 20px;
  border-radius: 999px;
  background: linear-gradient(180deg, #fefefe, #aac9ff);
  &::before {
    content: '';
    position: absolute;
    top: -6px;
    left: 50%;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    bottom: 2px;
    width: 164px;
    height: 42px;
    transform: translateX(-50%);
    background: linear-gradient(135deg, #54bfff, #7a41ff);
    box-shadow: 0 0 14px rgba(84, 191, 255, 0.65);
    border-radius: 50%;
    background: radial-gradient(
      ellipse at center,
      rgba(43, 126, 211, 0.32) 0%,
      rgba(43, 126, 211, 0.14) 46%,
      rgba(43, 126, 211, 0) 74%
    );
    filter: blur(2.6px);
    animation: baseGlow 4.6s ease-in-out infinite;
    z-index: 1;
  }
  &::after {
    content: '';
    position: absolute;
    left: 50%;
    bottom: 10px;
    width: 138px;
    height: 28px;
    transform: translateX(-50%);
    border-radius: 50%;
    border: 1px solid rgba(36, 116, 198, 0.6);
    box-shadow:
      inset 0 0 0 1px rgba(255, 255, 255, 0.58),
      0 0 22px rgba(42, 116, 196, 0.24);
    animation: basePulse 3.2s ease-in-out infinite;
    z-index: 4;
  }
}
.assistant-bot-antenna-left {
  left: 36px;
  transform: rotate(-14deg);
}
.assistant-bot-antenna-right {
  right: 36px;
  transform: rotate(14deg);
}
.assistant-bot-head {
.assistant-model-cut {
  position: relative;
  width: 132px;
  height: 178px;
  z-index: 6;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  transform-origin: center 84%;
  animation: avatarFloat 3.2s ease-in-out infinite;
}
.assistant-model-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center bottom;
  display: block;
  filter: saturate(1.03) drop-shadow(0 14px 18px rgba(22, 49, 79, 0.2));
  transition: filter 0.35s ease;
}
.assistant-model-fallback {
  width: 92px;
  height: 78px;
  border-radius: 28px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e8f1ff 100%);
  border: 1px solid rgba(0, 85, 212, 0.14);
  box-shadow: 0 16px 32px rgba(0, 85, 212, 0.14);
  height: 92px;
  border-radius: 24px;
  color: #fff;
  background: linear-gradient(145deg, rgba(31, 122, 114, 0.9), rgba(30, 91, 255, 0.9));
  border: 1px solid rgba(255, 255, 255, 0.3);
  box-shadow: 0 12px 24px rgba(31, 85, 173, 0.22);
  display: flex;
  align-items: center;
  justify-content: center;
}
.assistant-bot-head-glow {
  position: absolute;
  inset: 10px 16px auto;
  height: 20px;
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(0, 85, 212, 0.16), transparent);
}
.assistant-bot-eye {
  position: absolute;
  top: 30px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: radial-gradient(circle, #8ef0ff 0%, #56c0ff 42%, #2869ff 100%);
  box-shadow: 0 0 12px rgba(72, 186, 255, 0.72);
  animation: robotBlink 3.2s infinite;
}
.assistant-bot-eye-left {
  left: 22px;
}
.assistant-bot-eye-right {
  right: 22px;
}
.assistant-bot-mouth {
.assistant-base {
  position: absolute;
  left: 50%;
  bottom: 16px;
  width: 22px;
  height: 4px;
  bottom: 8px;
  transform: translateX(-50%);
  border-radius: 999px;
  background: linear-gradient(90deg, rgba(72, 186, 255, 0.2), rgba(72, 186, 255, 0.9), rgba(72, 186, 255, 0.2));
}
.assistant-bot-neck {
  width: 16px;
  height: 10px;
  border-radius: 0 0 10px 10px;
  background: linear-gradient(180deg, #dceaff, #bdd5ff);
  margin-top: -2px;
}
.assistant-bot-body {
  position: relative;
  width: 104px;
  height: 92px;
  margin-top: 2px;
  border-radius: 28px 28px 34px 34px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e3eeff 100%);
  border: 1px solid rgba(0, 85, 212, 0.14);
  box-shadow: 0 18px 36px rgba(0, 85, 212, 0.16);
  display: flex;
  align-items: center;
  justify-content: center;
}
.assistant-bot-arm {
  position: absolute;
  top: 18px;
  width: 16px;
  height: 44px;
  border-radius: 999px;
  background: linear-gradient(180deg, #eff5ff, #c7dbff);
  border: 1px solid rgba(0, 85, 212, 0.12);
}
.assistant-bot-arm-left {
  left: -10px;
  transform: rotate(16deg);
}
.assistant-bot-arm-right {
  right: -10px;
  transform: rotate(-16deg);
}
.assistant-bot-core {
  position: relative;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: $primary-blue;
  background: radial-gradient(circle, rgba(255, 255, 255, 1) 0%, #dae8ff 55%, #adc7ff 100%);
}
.assistant-bot-core-ring {
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 1px solid rgba(88, 135, 255, 0.3);
  border-top-color: rgba(255, 96, 139, 0.85);
  border-right-color: rgba(79, 145, 255, 0.9);
  border: 1px solid rgba(36, 116, 198, 0.28);
  background: radial-gradient(
    ellipse at center,
    rgba(255, 255, 255, 0.94) 0%,
    rgba(81, 164, 233, 0.16) 58%,
    rgba(30, 91, 255, 0.06) 100%
  );
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.assistant-status {
  position: relative;
  z-index: 1;
  margin-top: 14px;
  padding: 6px 12px;
  margin-top: 7px;
  padding: 5px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-size: 11px;
  font-weight: 600;
  color: $deep-blue;
  background: rgba(255, 255, 255, 0.95);
@@ -2595,81 +4531,25 @@
}
.assistant-base {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 50%;
  border: 2px solid rgba(255, 93, 122, 0.22);
  background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, rgba(255, 111, 145, 0.1) 70%, transparent 100%);
}
.assistant-base-lg {
  width: 118px;
  height: 30px;
  pointer-events: none;
}
.assistant-base-md {
  bottom: 6px;
  width: 88px;
  height: 20px;
  border-color: rgba(255, 93, 122, 0.34);
  bottom: 15px;
  width: 104px;
  height: 22px;
  border-color: rgba(36, 116, 198, 0.48);
  animation: basePulse 2.8s ease-in-out infinite;
}
.assistant-base-sm {
  bottom: 11px;
  width: 54px;
  height: 10px;
  background: linear-gradient(90deg, rgba(255, 93, 122, 0.95), rgba(255, 173, 188, 0.9));
  bottom: 20px;
  width: 68px;
  height: 14px;
  background: linear-gradient(90deg, rgba(31, 122, 114, 0.82), rgba(45, 124, 255, 0.9));
  border: none;
  box-shadow: 0 0 18px rgba(255, 93, 122, 0.38);
}
@keyframes robotBlink {
  0%, 44%, 48%, 100% {
    transform: scaleY(1);
  }
  46% {
    transform: scaleY(0.14);
  }
}
@keyframes robotBlinkFast {
  0%, 100% {
    transform: scaleY(1);
  }
  50% {
    transform: scaleY(0.3);
  }
}
@keyframes robotTalk {
  0%, 100% {
    transform: translateX(-50%) scaleX(1);
  }
  50% {
    transform: translateX(-50%) scaleX(1.35);
  }
}
@keyframes corePulse {
  0%, 100% {
    transform: scale(1);
    filter: brightness(1);
  }
  50% {
    transform: scale(1.08);
    filter: brightness(1.08);
  }
}
@keyframes coreRotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
  box-shadow: 0 0 18px rgba(45, 124, 255, 0.34);
  animation: basePulse 2.2s ease-in-out infinite;
}
@keyframes orbitRotate {
@@ -2710,9 +4590,85 @@
  }
}
.assistant-base-lg {
  width: 142px;
  height: 32px;
  animation: basePulse 3.4s ease-in-out infinite;
  &::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 130px;
    height: 130px;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: conic-gradient(
      from 180deg,
      transparent 0deg,
      rgba(36, 116, 198, 0.65) 48deg,
      transparent 114deg,
      rgba(36, 116, 198, 0.55) 212deg,
      transparent 286deg,
      rgba(31, 122, 114, 0.45) 334deg,
      transparent 360deg
    );
    -webkit-mask: radial-gradient(circle, transparent 61%, #000 62%, #000 68%, transparent 70%);
    mask: radial-gradient(circle, transparent 61%, #000 62%, #000 68%, transparent 70%);
    opacity: 0.62;
    animation: baseSpin 9s linear infinite;
  }
}
@keyframes avatarFloat {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-7px);
  }
}
@keyframes basePulse {
  0%,
  100% {
    transform: translateX(-50%) scale(1);
    opacity: 0.88;
  }
  50% {
    transform: translateX(-50%) scale(1.05);
    opacity: 0.98;
  }
}
@keyframes baseSpin {
  from {
    transform: translate(-50%, -50%) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) rotate(360deg);
  }
}
@keyframes baseGlow {
  0%,
  100% {
    transform: translateX(-50%) scaleX(1);
    opacity: 0.82;
  }
  50% {
    transform: translateX(-50%) scaleX(1.06);
    opacity: 0.96;
  }
}
.welcome-card {
  position: relative;
  padding: 14px 14px 12px;
  align-self: stretch;
  min-height: 206px;
  padding: 9px 10px 8px;
  border-radius: 16px;
  background:
    linear-gradient(#fff, #fff) padding-box,
@@ -2721,7 +4677,8 @@
  box-shadow: 0 16px 36px rgba(0, 85, 212, 0.12);
  &.compact {
    padding: 10px 12px;
    min-height: 160px;
    padding: 8px 9px 7px;
    border-radius: 12px;
    box-shadow: 0 8px 16px rgba(0, 85, 212, 0.07);
@@ -2730,8 +4687,8 @@
    }
    .welcome-title {
      font-size: 17px;
      line-height: 1.3;
      font-size: 16px;
      line-height: 1.25;
      br {
        display: none;
@@ -2739,65 +4696,69 @@
    }
    .welcome-desc {
      margin-top: 6px;
      font-size: 12px;
      line-height: 1.55;
      margin-top: 4px;
      font-size: 11px;
      line-height: 1.5;
    }
    .quick-prompt-list {
      margin-top: 10px;
      gap: 6px;
      margin-top: 8px;
      gap: 5px;
    }
    .quick-prompt-btn {
      padding: 8px 10px;
      font-size: 12px;
      padding: 7px 9px;
      font-size: 11px;
      border-radius: 7px;
    }
    .more-prompts-btn {
      margin-top: 8px;
      font-size: 12px;
      margin-top: 6px;
      font-size: 11px;
    }
  }
}
.welcome-eyebrow {
  font-size: 11px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 2px;
  color: rgba(0, 85, 212, 0.58);
  margin-bottom: 8px;
  margin-bottom: 5px;
}
.welcome-title {
  margin: 0;
  font-size: 26px;
  line-height: 1.2;
  font-size: 20px;
  line-height: 1.15;
  font-weight: 800;
  color: #172033;
  br {
    display: none;
  }
}
.welcome-desc {
  margin: 10px 0 0;
  font-size: 13px;
  line-height: 1.7;
  margin: 5px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: #5f6980;
}
.quick-prompt-list {
  display: grid;
  gap: 8px;
  margin-top: 14px;
  gap: 6px;
  margin-top: 8px;
}
.quick-prompt-btn {
  width: 100%;
  border: none;
  border-radius: 10px;
  padding: 11px 14px;
  border-radius: 9px;
  padding: 7px 10px;
  text-align: left;
  font-size: 13px;
  font-size: 12px;
  font-weight: 600;
  color: #fff;
  cursor: pointer;
@@ -2845,14 +4806,14 @@
}
.more-prompts-btn {
  margin-top: 10px;
  padding: 0 12px;
  height: 32px;
  margin-top: 6px;
  padding: 0 10px;
  height: 26px;
  border: 1px solid rgba(208, 65, 81, 0.12);
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 241, 245, 0.96));
  color: #d04151;
  font-size: 13px;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
@@ -2867,21 +4828,6 @@
    border-color: transparent;
    color: #fff;
    box-shadow: 0 14px 24px rgba(138, 61, 246, 0.18);
  }
}
.hero-dot-grid {
  display: grid;
  grid-template-columns: repeat(14, 1fr);
  gap: 7px;
  padding: 0 18px 14px;
  span {
    display: block;
    width: 100%;
    aspect-ratio: 1;
    border-radius: 2px;
    background: linear-gradient(135deg, rgba(255, 110, 138, 0.95), rgba(255, 190, 201, 0.55));
  }
}
@@ -2977,12 +4923,6 @@
  .welcome-title {
    font-size: 21px;
  }
  .hero-dot-grid {
    grid-template-columns: repeat(12, 1fr);
    gap: 6px;
    padding: 0 14px 12px;
  }
  .message-list {