gaoluyang
2026-05-06 10ffa7b2fa6a6c2ba5d8d031d51ffc8dc91119f2
src/components/AIChatSidebar/index.vue
@@ -2,46 +2,64 @@
  <div class="ai-chat-sidebar-wrapper">
    <!-- 悬浮图标 -->
    <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
      <el-tooltip content="AI 助手" placement="left">
      <el-tooltip :content="currentAssistant.tooltip" placement="left">
        <div class="trigger-icon">
          <el-icon :size="30" color="#fff"><Cpu /></el-icon>
          <el-icon :size="30" color="#fff"><component :is="currentAssistant.icon" /></el-icon>
        </div>
      </el-tooltip>
    </div>
    <!-- 侧边栏对话框 -->
    <el-drawer
      v-model="visible"
      :size="drawerSize"
      direction="rtl"
      :with-header="true"
      class="ai-chat-drawer"
      :modal="false"
      :show-close="true"
      :append-to-body="false"
      @close="handleClose"
        v-model="visible"
        :size="drawerSize"
        direction="rtl"
        :with-header="true"
        class="ai-chat-drawer"
        :modal="false"
        modal-class="ai-chat-overlay"
        :show-close="false"
        :append-to-body="false"
        @close="handleClose"
    >
      <template #header>
        <div class="drawer-header">
          <div class="header-left">
            <el-icon :size="20" class="header-icon"><Cpu /></el-icon>
            <span class="title">AI 智能助手</span>
            <el-icon :size="20" class="header-icon"><component :is="currentAssistant.icon" /></el-icon>
            <span class="title">{{ currentAssistant.title }}</span>
          </div>
          <div v-if="showAssistantSwitch" class="assistant-switcher">
            <el-radio-group v-model="selectedAssistantKey" size="small">
              <el-radio-button
                  v-for="assistant in assistants"
                  :key="assistant.key"
                  :label="assistant.key"
              >
                {{ assistant.label }}
              </el-radio-button>
            </el-radio-group>
          </div>
          <div class="header-actions">
            <el-tooltip content="会话历史" placement="bottom">
              <el-button link @click="toggleHistory">
              <el-button link class="header-action-btn" @click="handleToggleHistory">
                <el-icon :size="18"><Timer /></el-icon>
              </el-button>
            </el-tooltip>
            <el-tooltip content="开启新会话" placement="bottom">
              <el-button link @click="newChat">
              <el-button link class="header-action-btn" @click="handleNewChat">
                <el-icon :size="18"><Plus /></el-icon>
              </el-button>
            </el-tooltip>
            <div class="action-divider"></div>
            <el-tooltip content="关闭" placement="bottom">
              <el-button link class="header-action-btn close-btn" @click="handleManualClose">
                <el-icon :size="18"><Close /></el-icon>
              </el-button>
            </el-tooltip>
          </div>
        </div>
      </template>
      <div class="chat-container">
        <!-- 历史会话列表 -->
        <div v-if="showHistory" class="history-panel">
@@ -56,124 +74,273 @@
              </div>
            </template>
            <div class="session-list">
              <div
                v-for="session in sessions"
                :key="session.memoryId"
                :class="['session-item', { active: uuid === session.memoryId }]"
                @click="selectSession(session)"
              <div
                  v-for="session in sessions"
                  :key="session.memoryId"
                  :class="['session-item', { active: uuid === session.memoryId }]"
                  @click="selectSession(session)"
              >
                <el-icon><ChatDotSquare /></el-icon>
                <span class="session-name" :title="session.lastMessage || '新会话'">
                  {{ session.lastMessage || '新会话' }}
                </span>
                <el-button
                  link
                  type="danger"
                  class="delete-btn"
                  @click.stop="handleDeleteSession(session.memoryId)"
                <el-button
                    link
                    type="danger"
                    class="delete-btn"
                    @click.stop="handleDeleteSession(session.memoryId)"
                >
                  <el-icon><Delete /></el-icon>
                </el-button>
              </div>
              <el-empty v-if="sessions.length === 0" description="暂无历史会话" />
              <el-empty v-if="sessions.length === 0" :description="currentAssistant.emptySessionText" />
            </div>
          </el-skeleton>
        </div>
        <div v-else class="chat-main">
          <div :class="['chat-hero', { compact: hasMessages }]">
            <div :class="['assistant-stand', { thinking: isSending, compact: hasMessages }]">
              <div class="assistant-halo"></div>
              <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>
                  <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">
                <span class="assistant-status-dot"></span>
                {{ isSending ? '思考中...' : currentAssistant.label }}
              </div>
              <div class="assistant-base assistant-base-lg"></div>
              <div class="assistant-base assistant-base-md"></div>
              <div class="assistant-base assistant-base-sm"></div>
            </div>
            <div :class="['welcome-card', { compact: hasMessages }]">
              <div class="welcome-eyebrow">智能助手</div>
              <h3 class="welcome-title">
                您好
                <br />
                我是{{ currentAssistant.label }}分析解读助手
              </h3>
              <p class="welcome-desc">
                {{ currentAssistant.description || '我可以围绕业务问题提供解读、查询建议和分析支持,帮助你更快完成判断与处理。' }}
              </p>
              <div class="quick-prompt-list">
                <button
                    v-for="prompt in displayedQuickPrompts"
                    :key="prompt"
                    type="button"
                    class="quick-prompt-btn"
                    :disabled="isSending"
                    @click="sendQuickPrompt(prompt)"
                >
                  {{ prompt }}
                </button>
              </div>
              <button
                  v-if="quickPrompts.length > quickPromptLimit"
                  type="button"
                  class="more-prompts-btn"
                  @click="refreshQuickPrompts"
              >
                <el-icon><RefreshRight /></el-icon>
                <span>换一换</span>
              </button>
            </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"
            :key="index"
            :class="['message-item', message.isUser ? 'user-message' : 'bot-message']"
          >
            <div class="avatar">
              <el-icon v-if="message.isUser"><User /></el-icon>
              <el-icon v-else><Cpu /></el-icon>
            </div>
            <div class="message-content">
              <!-- 文本内容 -->
              <div class="text-box" v-html="message.htmlContent"></div>
              <!-- 图表内容 -->
              <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
                <div
                  v-for="(option, key) in message.chartOptions"
                  :key="key"
                  class="chart-item"
                  :id="`ai-chart-${index}-${key}`"
                ></div>
            <div
                v-for="(message, index) in messages"
                :key="index"
                :class="['message-item', message.isUser ? 'user-message' : 'bot-message']"
            >
              <div class="avatar">
                <el-icon v-if="message.isUser"><User /></el-icon>
                <el-icon v-else><Cpu /></el-icon>
              </div>
              <div class="message-content">
                <!-- 文本内容 -->
                <div class="text-box" v-html="message.htmlContent"></div>
              <!-- 表格内容 -->
              <div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper">
                <el-table :data="message.tableData.items" border stripe size="small" style="width: 100%">
                  <el-table-column
                    v-for="col in message.tableData.columns"
                    :key="col"
                    :prop="col"
                    :label="columnLabelMap[col] || col"
                    min-width="100"
                    show-overflow-tooltip
                <!-- 图表内容 -->
                <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
                  <div
                      v-for="(option, key) in message.chartOptions"
                      :key="key"
                      class="chart-item"
                      :id="`ai-chart-${index}-${key}`"
                  ></div>
                </div>
                <!-- 表格内容 -->
                <div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper">
                  <el-table :data="message.tableData.items" border stripe size="small" style="width: 100%">
                    <el-table-column
                        v-for="col in message.tableData.columns"
                        :key="col"
                        :prop="col"
                        :label="columnLabelMap[col] || col"
                        min-width="100"
                        show-overflow-tooltip
                    />
                  </el-table>
                </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>
                  <el-input
                      v-model="message.payloadText"
                      type="textarea"
                      :rows="8"
                      resize="vertical"
                      spellcheck="false"
                      class="payload-editor"
                  />
                </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="confirmPurchaseAnalysis(message)"
                    >
                      确认并执行
                    </el-button>
                  </div>
                </div>
              <!-- 打字中动画 -->
              <div v-if="message.isTyping" class="typing-indicator">
                <span class="dot"></span>
                <span class="dot"></span>
                <span class="dot"></span>
                <div v-if="message.isTyping" class="typing-indicator">
                  <span class="dot"></span>
                  <span class="dot"></span>
                  <span class="dot"></span>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="input-area">
          <div class="input-actions">
            <el-button link type="primary" size="small" @click="newChat">
              <el-icon><Plus /></el-icon>新会话
            </el-button>
            <el-button v-if="isSending" link type="danger" size="small" @click="stopGeneration">
              <el-icon><VideoPause /></el-icon>停止生成
            </el-button>
            <el-upload
              class="file-upload-trigger"
              action="#"
              :auto-upload="false"
              :show-file-list="false"
              :on-change="handleFileChange"
              :disabled="isSending"
            >
              <el-button link type="primary" size="small" :disabled="isSending">
                <el-icon><Upload /></el-icon>分析文件
          <div class="input-area">
            <div class="input-actions">
              <el-button link class="utility-action-btn" type="primary" size="small" @click="handleNewChat">
                <el-icon><Plus /></el-icon>新会话
              </el-button>
            </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>
              <el-button v-if="isSending" link class="utility-action-btn stop-action-btn" type="danger" size="small" @click="stopGeneration">
                <el-icon><VideoPause /></el-icon>停止生成
              </el-button>
              <el-upload
                  v-if="currentAssistant.allowFileUpload"
                  class="file-upload-trigger"
                  action="#"
                  :auto-upload="false"
                  :show-file-list="false"
                  v-model:file-list="uploadFileList"
                  :multiple="currentAssistant.allowMultipleFileUpload"
                  :on-change="handleFileChange"
                  :disabled="isSending"
              >
                <el-button link class="utility-action-btn upload-action-btn" type="primary" size="small" :disabled="isSending">
                  <el-icon><Upload /></el-icon>分析文件
                </el-button>
              </el-upload>
            </div>
            <el-input
              v-model="inputMessage"
              type="textarea"
              :rows="selectedFile ? 2 : 3"
              placeholder="请输入您的问题... (Enter 发送, Shift+Enter 换行)"
              resize="none"
              @keydown.enter.exact.prevent="sendMessage"
            />
            <el-button
              type="primary"
              class="send-btn"
              :disabled="isSending || (!inputMessage.trim() && !selectedFile)"
              @click="sendMessage"
            >
              发送
            </el-button>
            <div class="input-box">
              <div v-if="selectedFiles.length" class="selected-file-list">
                <div v-for="(file, fileIndex) in selectedFiles" :key="`${file.name}-${fileIndex}`" class="selected-file-tag">
                  <el-icon><Document /></el-icon>
                  <span class="file-name">{{ file.name }}</span>
                  <el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon>
                </div>
              </div>
              <el-input
                  v-model="inputMessage"
                  type="textarea"
                  :rows="selectedFiles.length ? 2 : 3"
                  :placeholder="currentAssistant.placeholder"
                  resize="none"
                  @keydown.enter.exact.prevent="sendMessage"
              />
              <el-button
                  type="primary"
                  class="send-btn"
                  :disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)"
                  @click="sendMessage"
                  aria-label="发送"
              >
                <el-icon><Promotion /></el-icon>
              </el-button>
            </div>
          </div>
        </div>
        </div>
      </div>
    </el-drawer>
@@ -184,8 +351,103 @@
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
import request from '@/utils/request'
import * as echarts from 'echarts'
import { Cpu, User, Plus, Loading, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close } from '@element-plus/icons-vue'
import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, ShoppingCart, Promotion, RefreshRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
  assistants: {
    type: Array,
    default: () => []
  },
  defaultAssistant: {
    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: true,
    allowMultipleFileUpload: true,
    fileAnalyzeUrl: '/purchase-ai/analyze-files',
    emptySessionText: '暂无采购会话'
  }
]
const assistants = computed(() => props.assistants?.length ? props.assistants : builtInAssistants)
const selectedAssistantKey = ref(props.defaultAssistant || assistants.value[0]?.key || 'general')
const currentAssistant = computed(() => assistants.value.find(item => item.key === selectedAssistantKey.value) || assistants.value[0] || builtInAssistants[0])
const showAssistantSwitch = computed(() => assistants.value.length > 1)
const assistantQuickPromptMap = {
  general: [
    '我当前有哪些审批待办需要处理?',
    '帮我列出今天新增的审批待办。',
    '当前待我审批的单据,按时间倒序列出来。',
    '我发起的审批里,哪些还在处理中?',
    '查询流程编号 XXX 的审批详情。',
    '流程编号 XXX 现在卡在哪个审批节点?当前审批人是谁?',
    '帮我查看流程编号 XXX 的审批流转记录。',
    '近7天我的审批待办统计情况怎么样?',
    '本月我的审批中,通过、驳回、处理中各有多少?',
    '近30天各类型审批数量分布是什么?',
    '帮我审批通过流程编号 XXX,备注“同意”。',
    '帮我驳回流程编号 XXX,备注“请补充说明”。',
    '撤销我刚刚对流程编号 XXX 的审批操作。',
    '帮我修改流程编号 XXX 的备注为“已补充附件”。',
    '删除我发起的流程编号 XXX。'
  ],
  purchase: [
    '本月采购金额排名前十的物料有哪些?',
    '哪些采购订单还未入库?',
    '最近7天供应商到货异常有哪些?',
    '帮我统计待付款采购单',
    '列出本月采购退货情况'
  ]
}
const quickPromptLimit = 3
const quickPromptStart = ref(0)
const quickPrompts = computed(() => {
  const assistant = currentAssistant.value || {}
  if (Array.isArray(assistant.quickPrompts) && assistant.quickPrompts.length) {
    return assistant.quickPrompts
  }
  return assistantQuickPromptMap[assistant.key] || assistantQuickPromptMap.general
})
const displayedQuickPrompts = computed(() => {
  const prompts = quickPrompts.value || []
  if (prompts.length <= quickPromptLimit) return prompts
  const result = []
  for (let i = 0; i < quickPromptLimit; i++) {
    result.push(prompts[(quickPromptStart.value + i) % prompts.length])
  }
  return result
})
const hasMessages = computed(() => messages.value.length > 0)
const visible = ref(false)
const windowWidth = ref(window.innerWidth)
@@ -198,17 +460,187 @@
const isSending = ref(false)
const currentAbortController = ref(null)
const inputMessage = ref('')
const selectedFile = ref(null)
const selectedFiles = ref([])
const uploadFileList = 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: '是否质检'
}
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'
}
// 历史会话相关
const showHistory = ref(false)
const sessions = ref([])
const loadingSessions = ref(false)
const abortCurrentRequest = () => {
  if (!currentAbortController.value) return
  currentAbortController.value.abort()
  currentAbortController.value = null
  isSending.value = false
  const lastMsg = messages.value[messages.value.length - 1]
  if (lastMsg && !lastMsg.isUser) {
    lastMsg.isTyping = false
  }
}
const toggleHistory = () => {
  showHistory.value = !showHistory.value
@@ -217,10 +649,17 @@
  }
}
const handleToggleHistory = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  toggleHistory()
}
const loadSessions = async () => {
  loadingSessions.value = true
  try {
    const res = await request.get('/xiaozhi/history/sessions')
    const res = await request.get(`${currentAssistant.value.apiBase}/history/sessions`)
    if (res.code === 200) {
      sessions.value = res.data || []
    }
@@ -234,21 +673,21 @@
const selectSession = async (session) => {
  showHistory.value = false
  uuid.value = session.memoryId
  localStorage.setItem('ai_chat_uuid', uuid.value)
  localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
  // 加载会话消息
  try {
    const res = await request.get(`/xiaozhi/history/messages/${uuid.value}`)
    const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`)
    if (res.code === 200) {
      disposeCharts()
      messages.value = []
      const historyMsgs = res.data || []
      // 重新构造消息列表并解析
      historyMsgs.forEach((msg, idx) => {
        const isUser = msg.role === 'user'
        const botMsgIndex = messages.value.length
        const messageObj = {
          isUser,
          content: msg.content,
@@ -259,9 +698,9 @@
          type: '',
          tableData: null
        }
        messages.value.push(messageObj)
        if (!isUser) {
          outputState.value[botMsgIndex] = {
            isPaused: false,
@@ -270,27 +709,13 @@
            blockEndPos: -1,
            hasRenderedChart: false
          }
          // 解析历史消息中的 JSON
          const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
          const jsonMatch = msg.content.match(jsonRegex)
          if (jsonMatch) {
            try {
              const parsedData = JSON.parse(jsonMatch[0])
              if (parsedData.success) {
                messageObj.type = parsedData.type || ''
                if (messageObj.type === 'todo_list' && parsedData.data) {
                  messageObj.tableData = parsedData.data
                }
                if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
                  messageObj.chartOptions = parsedData.charts
                  messageObj.chartRenderReady = true
                  renderCharts(botMsgIndex, messageObj.chartOptions)
                }
              }
            } catch (err) {}
          const extracted = extractEmbeddedSuccessJson(msg.content)
          if (extracted) {
            applyStructuredMessageData(messageObj, extracted.data, botMsgIndex)
          }
          updateOutputState(msg.content, botMsgIndex)
          messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex)
        } else {
@@ -306,7 +731,7 @@
const handleDeleteSession = async (memoryId) => {
  try {
    const res = await request.delete(`/xiaozhi/history/${memoryId}`)
    const res = await request.delete(`${currentAssistant.value.apiBase}/history/${memoryId}`)
    if (res.code === 200) {
      loadSessions()
      if (uuid.value === memoryId) {
@@ -342,6 +767,23 @@
  window.removeEventListener('resize', handleWindowResize)
})
watch(selectedAssistantKey, (nextKey, prevKey) => {
  if (!prevKey || nextKey === prevKey) return
  abortCurrentRequest()
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFiles.value = []
  uploadFileList.value = []
  inputMessage.value = ''
  quickPromptStart.value = 0
  initUUID()
  hello()
})
const handleWindowResize = () => {
  windowWidth.value = window.innerWidth
}
@@ -357,26 +799,57 @@
  visible.value = false
}
const handleManualClose = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  handleClose()
}
const initUUID = () => {
  let storedUUID = localStorage.getItem('ai_chat_uuid')
  let storedUUID = localStorage.getItem(currentAssistant.value.storageKey)
  if (!storedUUID) {
    storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4)
    localStorage.setItem('ai_chat_uuid', storedUUID)
    localStorage.setItem(currentAssistant.value.storageKey, storedUUID)
  }
  uuid.value = storedUUID
}
const hello = () => {
  sendRequest('你好')
  sendRequest(currentAssistant.value.welcomeMessage || '你好')
}
const newChat = () => {
  disposeCharts()
  messages.value = []
  outputState.value = {}
  localStorage.removeItem('ai_chat_uuid')
  sessions.value = []
  showHistory.value = false
  selectedFiles.value = []
  uploadFileList.value = []
  quickPromptStart.value = 0
  localStorage.removeItem(currentAssistant.value.storageKey)
  initUUID()
  hello()
}
const handleNewChat = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  newChat()
}
const sendQuickPrompt = (prompt) => {
  if (!prompt || isSending.value) return
  inputMessage.value = prompt
  sendMessage()
}
const refreshQuickPrompts = () => {
  const prompts = quickPrompts.value || []
  if (prompts.length <= quickPromptLimit) return
  quickPromptStart.value = (quickPromptStart.value + quickPromptLimit) % prompts.length
}
const disposeCharts = () => {
@@ -384,6 +857,519 @@
  resizeHandlers.value.forEach(handler => window.removeEventListener('resize', handler))
  chartInstances.value = {}
  resizeHandlers.value = []
}
const extractEmbeddedSuccessJson = (text) => {
  if (!text || typeof text !== 'string') 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
    let depth = 0
    let inString = false
    let escaped = false
    for (let j = i; j < text.length; j++) {
      const char = text[j]
      if (inString) {
        if (escaped) {
          escaped = false
        } else if (char === '\\') {
          escaped = true
        } else if (char === '"') {
          inString = false
        }
        continue
      }
      if (char === '"') {
        inString = true
        continue
      }
      if (char === '{') {
        depth++
      } else if (char === '}') {
        depth--
        if (depth === 0) {
          const candidate = text.slice(i, j + 1)
          try {
            const parsed = JSON.parse(candidate)
            if (parsed?.success === true) {
              return {
                data: parsed,
                startIdx: i,
                endIdx: j + 1
              }
            }
          } catch (err) {
            continue
          }
        }
      }
    }
  }
  return null
}
const applyStructuredMessageData = (messageObj, parsedData, msgIndex, shouldRenderCharts = true) => {
  if (!messageObj || !parsedData?.success) return
  messageObj.type = parsedData.type || ''
  if (messageObj.type === 'todo_list' && parsedData.data) {
    messageObj.tableData = parsedData.data
  }
  if (parsedData.action === 'confirm_required' && parsedData.businessType) {
    messageObj.type = 'purchase_analysis_confirm'
    messageObj.purchaseAnalysisData = parsedData
    if (!messageObj.payloadText) {
      messageObj.payloadText = JSON.stringify(localizePurchasePayload(parsedData.payload || {}), 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) {
      renderCharts(msgIndex, messageObj.chartOptions)
      if (outputState.value[msgIndex]) {
        outputState.value[msgIndex].hasRenderedChart = true
      }
    }
  }
}
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 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]) => {
      result[keyMap[key] || key] = mapPayloadKeys(item, keyMap)
      return result
    }, {})
  }
  return value
}
const localizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldLabelMap)
const normalizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldKeyMap)
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 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 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: Array.isArray(ledger.productData)
      ? ledger.productData.map(normalizePurchaseProductRecord)
      : []
  }))
  const unmatchedProducts = []
  payload.productData.map(normalizePurchaseProductRecord).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: Array.isArray(record.productData)
      ? record.productData.map(normalizePurchaseProductRecord)
      : record.productData
  }
  return Object.entries(normalizedRecord).reduce((result, [key, value]) => {
    if (purchaseLedgerAllowedFieldKeys.has(key)) {
      result[key] = value
    }
    return result
  }, {})
}
const sanitizePurchasePayloadForSubmit = (payload, businessType) => {
  if (businessType !== 'purchase_ledger' || !payload || typeof payload !== 'object') return payload
  const sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload })
  if (Array.isArray(sanitized.purchaseLedgers)) {
    sanitized.purchaseLedgers = sanitized.purchaseLedgers.map(filterPurchaseLedgerRecord)
  }
  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 => !purchaseApprovalFieldKeys.has(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 scrollToBottom = () => {
@@ -394,30 +1380,38 @@
  })
}
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
  })
  selectedFiles.value = validFiles
  uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw))
}
const removeSelectedFile = () => {
  selectedFile.value = null
const removeSelectedFile = (index) => {
  selectedFiles.value.splice(index, 1)
  uploadFileList.value.splice(index, 1)
}
const analyzeFile = async (file, message = '') => {
const analyzeFiles = async (files, message = '') => {
  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,
@@ -448,13 +1442,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('/xiaozhi/analyze-file', formData, {
  const analyzeUrl = currentAssistant.value.fileAnalyzeUrl || `${currentAssistant.value.apiBase}/analyze-file`
  request.post(analyzeUrl, formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    },
@@ -462,34 +1458,16 @@
    onDownloadProgress: (e) => {
      const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
      if (!fullText) return
      const currentMsg = messages.value[botMsgIndex]
      if (!currentMsg) return
      currentMsg.content = fullText
      // 解析 JSON 数据(针对嵌入式 JSON)
      const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
      const jsonMatch = fullText.match(jsonRegex)
      if (jsonMatch) {
        try {
          const parsedData = JSON.parse(jsonMatch[0])
          if (parsedData.success) {
            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
              if (!outputState.value[botMsgIndex].hasRenderedChart) {
                renderCharts(botMsgIndex, currentMsg.chartOptions)
                outputState.value[botMsgIndex].hasRenderedChart = true
              }
            }
          }
        } catch (err) {}
      const extracted = extractEmbeddedSuccessJson(fullText)
      if (extracted) {
        applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart)
      }
      updateOutputState(fullText, botMsgIndex)
@@ -501,7 +1479,12 @@
    currentMsg.isTyping = false
    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)
@@ -510,6 +1493,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)
@@ -526,10 +1511,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) {
      analyzeFiles([...selectedFiles.value], msg)
      selectedFiles.value = []
      uploadFileList.value = []
    } else {
      sendRequest(msg)
    }
@@ -538,17 +1524,7 @@
}
const stopGeneration = () => {
  if (currentAbortController.value) {
    currentAbortController.value.abort()
    currentAbortController.value = null
    isSending.value = false
    // 将最后一条消息标记为非打字状态
    const lastMsg = messages.value[messages.value.length - 1]
    if (lastMsg && !lastMsg.isUser) {
      lastMsg.isTyping = false
    }
  }
  abortCurrentRequest()
}
const sendRequest = (message) => {
@@ -556,14 +1532,12 @@
  currentAbortController.value = new AbortController()
  // 用户消息
  if (messages.value.length > 0) {
    messages.value.push({
      isUser: true,
      content: message,
      htmlContent: convertTextToHtml(message),
      isTyping: false
    })
  }
  messages.value.push({
    isUser: true,
    content: message,
    htmlContent: convertTextToHtml(message),
    isTyping: false
  })
  // 机器人占位
  const botMsgIndex = messages.value.length
@@ -578,7 +1552,7 @@
    tableData: null
  }
  messages.value.push(botMsg)
  outputState.value[botMsgIndex] = {
    isPaused: false,
    jsonBlockStartPos: -1,
@@ -586,86 +1560,84 @@
    blockEndPos: -1,
    hasRenderedChart: false
  }
  scrollToBottom()
  request.post('/xiaozhi/chat',
    { memoryId: uuid.value, message },
    {
      signal: currentAbortController.value.signal,
      onDownloadProgress: (e) => {
        // 兼容不同版本的 axios 获取响应文本的方式
        const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
        if (!fullText) return
        const currentMsg = messages.value[botMsgIndex]
        if (!currentMsg) return
        currentMsg.content = fullText
  request.post(`${currentAssistant.value.apiBase}/chat`,
      { memoryId: uuid.value, message },
      {
        signal: currentAbortController.value.signal,
        onDownloadProgress: (e) => {
          // 兼容不同版本的 axios 获取响应文本的方式
          const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
          if (!fullText) return
        // 解析 JSON 数据(针对嵌入式 JSON)
        const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
        const jsonMatch = fullText.match(jsonRegex)
          const currentMsg = messages.value[botMsgIndex]
          if (!currentMsg) return
        if (jsonMatch) {
          try {
            const parsedData = JSON.parse(jsonMatch[0])
            if (parsedData.success) {
              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
                if (!outputState.value[botMsgIndex].hasRenderedChart) {
                  renderCharts(botMsgIndex, currentMsg.chartOptions)
                  outputState.value[botMsgIndex].hasRenderedChart = true
                }
          currentMsg.content = fullText
          // 尝试提取并解析 JSON
          const extracted = extractEmbeddedSuccessJson(fullText)
          if (extracted) {
            applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
          } else {
            const extractJson = (text) => {
              const startIdx = text.indexOf('{"success": true')
              if (startIdx === -1) return null
              // 从后往前找最后一个 '}'
              const lastBraceIdx = text.lastIndexOf('}')
              if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
              const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
              try {
                return JSON.parse(potentialJson)
              } catch (err) {
                return null
              }
            }
          } catch (err) {}
        }
        updateOutputState(fullText, botMsgIndex)
        currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
        scrollToBottom()
            const parsedData = extractJson(fullText)
            if (parsedData) {
              applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true)
            }
          }
          updateOutputState(fullText, botMsgIndex)
          currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
          scrollToBottom()
        }
      }
    }
  ).then(() => {
    const currentMsg = messages.value[botMsgIndex]
    currentMsg.isTyping = false
    isSending.value = false
    currentAbortController.value = null
    // 最终解析
    const fullText = currentMsg.content
    const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
    const jsonMatch = fullText.match(jsonRegex)
    if (jsonMatch) {
      try {
        const parsedData = JSON.parse(jsonMatch[0])
        if (parsedData.success) {
          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
            if (!outputState.value[botMsgIndex].hasRenderedChart) {
              renderCharts(botMsgIndex, currentMsg.chartOptions)
              outputState.value[botMsgIndex].hasRenderedChart = true
            }
          }
          currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
    const extracted = extractEmbeddedSuccessJson(currentMsg.content)
    if (extracted) {
      applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
    } else {
      const extractJson = (text) => {
        const startIdx = text.indexOf('{"success": true')
        if (startIdx === -1) return null
        const lastBraceIdx = text.lastIndexOf('}')
        if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
        const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
        try {
          return JSON.parse(potentialJson)
        } catch (err) {
          return null
        }
      } catch (err) {}
    }
    // 兜底渲染
    if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) {
      renderCharts(botMsgIndex, currentMsg.chartOptions)
      }
      const finalParsed = extractJson(currentMsg.content)
      if (finalParsed) {
        applyStructuredMessageData(currentMsg, finalParsed, botMsgIndex)
      }
    }
  }).catch(err => {
    if (err.name === 'CanceledError' || err.name === 'AbortError') {
@@ -704,10 +1676,10 @@
const convertTextToHtml = (text) => {
  if (!text) return ''
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/\n/g, '<br>')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>')
}
const convertStreamOutput = (output, msgIndex) => {
@@ -715,29 +1687,87 @@
  const state = outputState.value[msgIndex]
  let display = output
  const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
  const jsonMatch = output.match(jsonRegex)
  if (jsonMatch) {
    try {
      const parsed = JSON.parse(jsonMatch[0])
      display = output.replace(jsonMatch[0], '').trim()
      if (!display && parsed.description) display = parsed.description
    } catch (e) {
      const start = output.search(/\{"success":\s*true/)
      display = output.substring(0, start) + '... (正在生成数据图表)'
  // 尝试提取 JSON 部分
  const extracted = extractEmbeddedSuccessJson(output)
  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) {
    const startPos = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos : state.jsBlockStartPos
    const textBeforeBlock = display.substring(0, startPos).trim()
    display = textBeforeBlock || '正在分析数据并生成图表...'
    return convertTextToHtml(display)
  }
  if (extracted) {
    const parsed = extracted.data
    display = (output.substring(0, extracted.startIdx) + output.substring(extracted.endIdx)).trim()
    if (/^[\s}\],,。.::;;]+$/.test(display)) {
      display = ''
    }
    if (parsed.description) {
      display = parsed.action === 'confirm_required'
        ? getPurchaseConfirmDescription(parsed)
        : parsed.description
    }
    if (!display) {
      if (parsed.type === 'todo_list') {
        display = '已为您整理好相关数据。'
      } else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
        display = '已为您生成分析图表。'
      } else {
        display = '正在为您展示分析结果...'
      }
    }
  } else if (startIdx !== -1) {
    const lastBraceIdx = output.lastIndexOf('}')
    if (lastBraceIdx !== -1 && lastBraceIdx > startIdx) {
      const potentialJson = output.substring(startIdx, lastBraceIdx + 1)
      try {
        const parsed = JSON.parse(potentialJson)
        // 成功解析,移除 JSON 部分显示文字
        display = (output.substring(0, startIdx) + output.substring(lastBraceIdx + 1)).trim()
        if (/^[\s}\],,。.::;;]+$/.test(display)) {
          display = ''
        }
        if (parsed.description) {
          display = parsed.action === 'confirm_required'
            ? getPurchaseConfirmDescription(parsed)
            : parsed.description
        }
        if (!display) {
          if (parsed.type === 'todo_list') {
            display = '已为您整理好相关数据:'
          } else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
            display = '已为您生成分析图表:'
          } else {
            display = '正在为您展示分析结果...'
          }
        }
      } catch (e) {
        // 解析失败,说明 JSON 还在传输中或格式不正确
        display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
      }
    } else {
      // 找到了开始但还没找到结束
      display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
    }
  }
  if (state.jsonBlockStartPos !== -1 && state.blockEndPos === -1) {
    display = display.substring(0, state.jsonBlockStartPos)
  } else if (state.jsBlockStartPos !== -1 && state.blockEndPos === -1) {
    display = display.substring(0, state.jsBlockStartPos)
  }
  let html = convertTextToHtml(display)
  display = display.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>')
  display = display.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
  // 还原代码块
  html = html.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>')
  html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
  return convertTextToHtml(display)
  return html || '...'
}
const renderCharts = (msgIndex, chartOptions) => {
@@ -747,14 +1777,27 @@
      const tryInit = (count = 0) => {
        const dom = document.getElementById(id)
        if (dom) {
          if (chartInstances.value[id]) chartInstances.value[id].dispose()
          if (chartInstances.value[id]) {
            // 如果已经初始化过,直接更新数据
            const chart = chartInstances.value[id]
            const option = normalizeAiChartOption(chartOptions[key])
            if (option) chart.setOption(option)
            return
          }
          const chart = echarts.init(dom)
          chartInstances.value[id] = chart
          chart.setOption(chartOptions[key])
          const option = normalizeAiChartOption(chartOptions[key])
          if (option) {
            chart.setOption(option)
          } else {
            console.warn('Invalid chart option for:', id, chartOptions[key])
          }
          const handler = () => chart.resize()
          resizeHandlers.value.push(handler)
          window.addEventListener('resize', handler)
        } else if (count < 10) {
        } else if (count < 15) { // 稍微增加重试次数
          setTimeout(() => tryInit(count + 1), 200)
        }
      }
@@ -763,19 +1806,198 @@
  })
}
// 格式化 AI 返回的图表配置,将其转换为标准的 ECharts 配置
const formatChartOption = (rawOption) => {
  if (!rawOption) return null
  // 如果已经是标准 ECharts 配置(包含 series),则直接返回
  const hasSeries = rawOption.series && Array.isArray(rawOption.series)
  // 尝试转换简易格式
  try {
    const isPie = rawOption.type === 'pie' || (rawOption.title && rawOption.title.includes('占比'))
    const option = {
      title: {
        text: rawOption.title || '',
        left: 'center',
        textStyle: { fontSize: 14 }
      },
      tooltip: {
        trigger: isPie ? 'item' : 'axis'
      },
      legend: {
        bottom: '0'
      },
      grid: {
        left: '3%',
        right: '4%',
        bottom: '15%',
        containLabel: true
      },
      xAxis: isPie ? undefined : {
        type: 'category',
        data: rawOption.xAxisData || (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : []),
        name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : ''
      },
      yAxis: isPie ? undefined : {
        type: 'value',
        name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
      },
      series: rawOption.series || [{
        name: rawOption.title || '数值',
        type: rawOption.type || 'line',
        data: rawOption.seriesData || (Array.isArray(rawOption.data) ? rawOption.data : []),
        smooth: true,
        radius: isPie ? '50%' : undefined
      }]
    }
    // 针对饼图的特殊处理
    if (isPie && !option.series[0].data && Array.isArray(rawOption.data)) {
      option.series[0].data = rawOption.data
    }
    return option
  } catch (err) {
    console.error('Chart option conversion failed:', err)
    return null
  }
}
const normalizeAiChartOption = (rawOption) => {
  if (!rawOption) return null
  try {
    const hasSeries = Array.isArray(rawOption.series) && rawOption.series.length > 0
    const firstSeriesType = hasSeries ? rawOption.series[0]?.type : rawOption.type
    const titleConfig = rawOption.title && typeof rawOption.title === 'object'
      ? rawOption.title
      : null
    const tooltipConfig = rawOption.tooltip && typeof rawOption.tooltip === 'object'
      ? rawOption.tooltip
      : null
    const legendConfig = rawOption.legend && typeof rawOption.legend === 'object'
      ? rawOption.legend
      : null
    const rawXAxisConfig = rawOption.xAxis && typeof rawOption.xAxis === 'object' && !Array.isArray(rawOption.xAxis)
      ? rawOption.xAxis
      : null
    const rawYAxisConfig = rawOption.yAxis && typeof rawOption.yAxis === 'object' && !Array.isArray(rawOption.yAxis)
      ? rawOption.yAxis
      : null
    const titleText = typeof rawOption.title === 'string' ? rawOption.title : rawOption.title?.text || ''
    const isPie = firstSeriesType === 'pie' || titleText.includes('占比')
    const baseXAxisData = Array.isArray(rawOption.xAxisData)
        ? rawOption.xAxisData
        : (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : (Array.isArray(rawXAxisConfig?.data) ? rawXAxisConfig.data : []))
    const fallbackSeries = [{
      name: titleText || '数据',
      type: rawOption.type || 'line',
      data: Array.isArray(rawOption.seriesData) ? rawOption.seriesData : (Array.isArray(rawOption.data) ? rawOption.data : [])
    }]
    const normalizedSeries = (hasSeries ? rawOption.series : fallbackSeries).map((seriesItem, index) => {
      const seriesType = seriesItem?.type || rawOption.type || 'line'
      const nextSeries = {
        ...seriesItem,
        name: seriesItem?.name || titleText || `系列${index + 1}`,
        type: seriesType
      }
      if (isPie) {
        nextSeries.radius = nextSeries.radius || '55%'
        nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : (Array.isArray(rawOption.data) ? rawOption.data : [])
      } else {
        nextSeries.smooth = typeof nextSeries.smooth === 'boolean' ? nextSeries.smooth : seriesType === 'line'
        nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : []
      }
      return nextSeries
    })
    const categorySource = !isPie
      ? normalizedSeries.find(seriesItem => Array.isArray(seriesItem.data) && seriesItem.data.every(item => item && typeof item === 'object' && 'name' in item && 'value' in item))
      : null
    const xAxisData = categorySource
      ? categorySource.data.map(item => item.name)
      : baseXAxisData
    const finalSeries = !isPie && categorySource
      ? normalizedSeries.map(seriesItem => ({
          ...seriesItem,
          data: Array.isArray(seriesItem.data)
            ? seriesItem.data.map(item => (item && typeof item === 'object' && 'value' in item ? item.value : item))
            : []
        }))
      : normalizedSeries
    return {
      title: titleConfig || {
        text: titleText,
        left: 'center',
        textStyle: { fontSize: 14 }
      },
      tooltip: tooltipConfig || {
        trigger: isPie ? 'item' : 'axis'
      },
      legend: legendConfig || {
        bottom: '0'
      },
      grid: isPie ? undefined : {
        left: '3%',
        right: '4%',
        bottom: '15%',
        containLabel: true
      },
      xAxis: isPie ? undefined : {
        ...(rawXAxisConfig || {}),
        type: 'category',
        data: xAxisData,
        name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : (rawXAxisConfig?.name || '')
      },
      yAxis: isPie ? undefined : (rawYAxisConfig || {
        type: 'value',
        name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
      }),
      series: finalSeries
    }
  } catch (err) {
    console.error('AI chart normalization failed:', err, rawOption)
    return formatChartOption(rawOption)
  }
}
watch(messages, () => scrollToBottom(), { deep: true })
</script>
<style scoped lang="scss">
.ai-chat-sidebar-wrapper {
  position: fixed;
  inset: 0;
  z-index: 2000;
  pointer-events: none;
<style lang="scss">
.ai-chat-overlay {
  pointer-events: none !important;
  background: transparent !important;
}
  :deep(.el-drawer__container) {
    pointer-events: none;
  }
.ai-chat-overlay .el-drawer {
  pointer-events: auto;
}
</style>
<style scoped lang="scss">
$primary-blue: #0055d4;
$secondary-blue: #2e8ce0;
$light-blue: #7ab8ff;
$pale-blue: #c5dcff;
$ice-white: #e8f2ff;
$deep-blue: #003b8e;
$deepest-blue: #002b66;
$gradient-blue: linear-gradient(145deg, #004fc7 0%, #0066e0 40%, #2580e8 70%, #5a9fe0 100%);
$gradient-dark: linear-gradient(145deg, #003b8e 0%, #0055d4 50%, #0077e8 100%);
$gradient-ice: linear-gradient(180deg, #e0ecff 0%, #d4e5ff 50%, #e8f0ff 100%);
$shadow-blue: 0 8px 40px rgba(0, 85, 212, 0.35);
$shadow-deep: 0 12px 48px rgba(0, 40, 120, 0.4);
$shadow-card: 0 6px 24px rgba(0, 51, 136, 0.12);
.ai-chat-sidebar-wrapper {
  position: static;
  z-index: 2000;
  pointer-events: auto;
  :deep(.el-drawer) {
    pointer-events: auto;
@@ -785,29 +2007,82 @@
.ai-chat-trigger {
  pointer-events: auto;
  position: fixed;
  right: 20px;
  right: 24px;
  bottom: 100px;
  width: 60px;
  height: 60px;
  background: linear-gradient(135deg, #409eff 0%, #007aff 100%);
  width: 56px;
  height: 56px;
  background: $gradient-dark;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
  transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  box-shadow: $shadow-deep, 0 0 0 2px rgba(0, 85, 212, 0.3) inset, 0 0 30px rgba(0, 119, 232, 0.2);
  transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  z-index: 2001;
  animation: triggerPulse 3s ease-in-out infinite;
  &::before {
    content: '';
    position: absolute;
    inset: -6px;
    background: linear-gradient(135deg, rgba(0, 85, 212, 0.4), rgba(0, 136, 232, 0.3), rgba(90, 159, 224, 0.2));
    border-radius: 50%;
    z-index: -1;
    filter: blur(16px);
    animation: glowPulse 2s ease-in-out infinite alternate;
  }
  &::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, transparent 50%);
    pointer-events: none;
  }
  &:hover {
    transform: scale(1.1) translateY(-5px);
    box-shadow: 0 8px 20px rgba(0, 122, 255, 0.5);
    transform: scale(1.12) translateY(-4px);
    box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.4) inset, 0 0 50px rgba(0, 136, 232, 0.3);
    &::before {
      animation: glowPulse 1s ease-in-out infinite alternate;
    }
    .trigger-icon {
      transform: rotate(-8deg) scale(1.05);
      filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.5));
    }
  }
  .trigger-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    color: #fff;
    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
  }
}
@keyframes triggerPulse {
  0%, 100% {
    box-shadow: $shadow-blue, 0 0 0 2px rgba(0, 85, 212, 0.25) inset, 0 0 20px rgba(0, 119, 232, 0.15);
  }
  50% {
    box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.35) inset, 0 0 40px rgba(0, 136, 232, 0.25);
  }
}
@keyframes glowPulse {
  0% {
    opacity: 0.6;
    transform: scale(1);
  }
  100% {
    opacity: 1;
    transform: scale(1.1);
  }
}
@@ -815,13 +2090,15 @@
  :deep(.el-drawer__body) {
    padding: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    height: 100%;
  }
  :deep(.el-drawer__header) {
    margin-bottom: 0;
    padding: 12px 16px;
    background: #fff;
    border-bottom: 1px solid #ebeef5;
    color: #303133;
    padding: 0;
    background: $gradient-dark;
    color: #fff;
  }
}
@@ -830,27 +2107,202 @@
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding-right: 32px;
  padding: 18px 20px;
  background: $gradient-dark;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    top: -60%;
    right: -25%;
    width: 250px;
    height: 250px;
    background: radial-gradient(circle, rgba(0, 136, 232, 0.4) 0%, transparent 70%);
    pointer-events: none;
    animation: headerGlow 4s ease-in-out infinite alternate;
  }
  &::after {
    content: '';
    position: absolute;
    bottom: -40%;
    left: -15%;
    width: 200px;
    height: 200px;
    background: radial-gradient(circle, rgba(0, 85, 212, 0.3) 0%, transparent 70%);
    pointer-events: none;
    animation: headerGlow 5s ease-in-out infinite alternate-reverse;
  }
  .header-left {
    display: flex;
    align-items: center;
    gap: 8px;
    gap: 12px;
    position: relative;
    z-index: 1;
    .header-icon {
      color: #409eff;
      color: rgba(255, 255, 255, 0.95);
      filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
      animation: iconFloat 3s ease-in-out infinite;
    }
    .title {
      font-size: 16px;
      font-size: 17px;
      font-weight: 600;
      color: #303133;
      color: #fff;
      text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
      letter-spacing: 0.5px;
    }
  }
  .header-actions {
    display: flex;
    gap: 8px;
    align-items: center;
    gap: 10px;
    position: relative;
    z-index: 1;
    .action-divider {
      width: 1px;
      height: 16px;
      background: rgba(255, 255, 255, 0.2);
      margin: 0 2px;
    }
    :deep(.el-button) {
      color: rgba(255, 255, 255, 0.85);
      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      background: rgba(255, 255, 255, 0.12);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 8px;
      padding: 8px;
      height: 32px;
      width: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      &:hover {
        color: #fff;
        background: rgba(255, 255, 255, 0.25);
        border-color: rgba(255, 255, 255, 0.3);
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      }
      &:active {
        transform: translateY(0);
      }
      &.close-btn {
        background: rgba(255, 255, 255, 0.1);
        &:hover {
          background: rgba(245, 108, 108, 0.8);
          border-color: rgba(245, 108, 108, 0.5);
        }
      }
    }
    :deep(.header-action-btn) {
      position: relative;
      overflow: hidden;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08));
      border: 1px solid rgba(255, 255, 255, 0.16);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.12);
      &::before {
        content: '';
        position: absolute;
        inset: 0;
        background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 55%);
        pointer-events: none;
      }
      &::after {
        content: '';
        position: absolute;
        top: -120%;
        left: -40%;
        width: 60%;
        height: 260%;
        background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.28), transparent);
        transform: rotate(24deg);
        opacity: 0;
        transition: all 0.35s ease;
      }
      &:hover::after {
        left: 100%;
        opacity: 1;
      }
    }
  }
  .assistant-switcher {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    padding: 0 12px;
    position: relative;
    z-index: 1;
    :deep(.el-radio-group) {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
      justify-content: center;
      padding: 4px;
      border-radius: 999px;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.08));
      border: 1px solid rgba(255, 255, 255, 0.12);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.1);
    }
    :deep(.el-radio-button__inner) {
      border-radius: 999px;
      border: 1px solid rgba(255, 255, 255, 0.18);
      background: rgba(255, 255, 255, 0.12);
      color: rgba(255, 255, 255, 0.86);
      box-shadow: none;
      padding: 7px 14px;
      font-weight: 500;
    }
    :deep(.el-radio-button:first-child .el-radio-button__inner),
    :deep(.el-radio-button:last-child .el-radio-button__inner) {
      border-radius: 999px;
    }
    :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
      background: #fff;
      color: $primary-blue;
      border-color: #fff;
      box-shadow: 0 6px 14px rgba(0, 40, 120, 0.16);
    }
  }
}
@keyframes headerGlow {
  0% {
    opacity: 0.6;
    transform: scale(1);
  }
  100% {
    opacity: 1;
    transform: scale(1.15);
  }
}
@keyframes iconFloat {
  0%, 100% {
    transform: translateY(0) rotate(0);
  }
  50% {
    transform: translateY(-2px) rotate(3deg);
  }
}
@@ -858,61 +2310,122 @@
  display: flex;
  flex-direction: column;
  height: 100%;
  background-color: #f5f7fa;
  width: 100%;
  background: $ice-white;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 240px;
    background: linear-gradient(180deg, rgba(0, 85, 212, 0.06) 0%, transparent 100%);
    pointer-events: none;
  }
}
.history-panel {
  position: absolute;
  inset: 0;
  background: #fff;
  background: linear-gradient(180deg, #fff 0%, $ice-white 100%);
  z-index: 10;
  display: flex;
  flex-direction: column;
  box-shadow: -8px 0 32px rgba(0, 85, 212, 0.15);
  .history-header {
    padding: 16px;
    border-bottom: 1px solid #ebeef5;
    padding: 18px 20px;
    border-bottom: 1px solid rgba(0, 85, 212, 0.12);
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-weight: 600;
    font-size: 14px;
    color: $deep-blue;
    background: linear-gradient(135deg, rgba(0, 85, 212, 0.08) 0%, rgba(0, 136, 232, 0.05) 100%);
    position: relative;
    &::after {
      content: '';
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 1px;
      background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.2), transparent);
    }
  }
  .session-list {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
    padding: 12px 16px;
    &::-webkit-scrollbar {
      width: 8px;
    }
    &::-webkit-scrollbar-thumb {
      background: linear-gradient(180deg, $secondary-blue, $primary-blue);
      border-radius: 4px;
      box-shadow: 0 0 6px rgba(0, 85, 212, 0.25);
    }
    .session-item {
      display: flex;
      align-items: center;
      padding: 12px;
      margin-bottom: 4px;
      border-radius: 8px;
      padding: 14px 16px;
      margin-bottom: 6px;
      border-radius: 12px;
      cursor: pointer;
      transition: all 0.2s;
      gap: 10px;
      transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
      gap: 12px;
      position: relative;
      border: 1px solid transparent;
      background: #fff;
      animation: sessionSlideIn 0.35s ease;
      @keyframes sessionSlideIn {
        from {
          opacity: 0;
          transform: translateX(-15px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      &:hover {
        background-color: #f5f7fa;
        background: linear-gradient(135deg, rgba(0, 85, 212, 0.06) 0%, rgba(0, 136, 232, 0.08) 100%);
        border-color: rgba(0, 85, 212, 0.12);
        box-shadow: 0 4px 16px rgba(0, 85, 212, 0.1);
        transform: translateX(4px);
        .delete-btn {
          opacity: 1;
          transform: scale(1);
        }
      }
      &.active {
        background-color: #ecf5ff;
        border-color: #d9ecff;
        color: #409eff;
        background: linear-gradient(135deg, rgba(0, 85, 212, 0.12) 0%, rgba(0, 136, 232, 0.15) 100%);
        border-color: rgba(0, 85, 212, 0.25);
        color: $primary-blue;
        box-shadow: 0 4px 16px rgba(0, 85, 212, 0.15);
        .el-icon {
          color: $primary-blue;
        }
      }
      .el-icon {
        font-size: 16px;
        font-size: 18px;
        flex-shrink: 0;
        color: $secondary-blue;
        transition: color 0.2s;
      }
      .session-name {
@@ -921,14 +2434,22 @@
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        color: #1a1a2e;
        font-weight: 500;
      }
      .delete-btn {
        opacity: 0;
        transition: opacity 0.2s;
        padding: 4px;
        transform: scale(0.8);
        transition: all 0.25s ease;
        padding: 6px;
        border-radius: 6px;
        color: #c0c4cc;
        &:hover {
          color: #f56c6c;
          color: #fff;
          background: rgba(245, 108, 108, 0.85);
          transform: scale(1.1) rotate(8deg);
        }
      }
    }
@@ -946,58 +2467,98 @@
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  padding: 24px 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
  background: linear-gradient(180deg, transparent 0%, rgba(0, 85, 212, 0.02) 100%);
  &::-webkit-scrollbar {
    width: 6px;
    width: 8px;
  }
  &::-webkit-scrollbar-thumb {
    background: #dcdfe6;
    border-radius: 3px;
    background: linear-gradient(180deg, $secondary-blue, $primary-blue);
    border-radius: 4px;
    box-shadow: 0 0 8px rgba(0, 85, 212, 0.3);
  }
}
.message-item {
  display: flex;
  gap: 12px;
  gap: 14px;
  width: 100%;
  animation: messageSlideIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  @keyframes messageSlideIn {
    from {
      opacity: 0;
      transform: translateY(20px) scale(0.95);
    }
    to {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
  }
  .avatar {
    width: 36px;
    height: 36px;
    border-radius: 8px;
    width: 42px;
    height: 42px;
    border-radius: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    font-size: 20px;
    font-size: 24px;
    position: relative;
    overflow: hidden;
    &::before {
      content: '';
      position: absolute;
      inset: 0;
      background: inherit;
      filter: blur(10px);
      opacity: 0.5;
      z-index: -1;
    }
    &::after {
      content: '';
      position: absolute;
      top: -50%;
      left: -50%;
      width: 200%;
      height: 200%;
      background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.2) 50%, transparent 60%);
      animation: shimmer 3s infinite;
    }
  }
  .message-content {
    flex: 1;
    overflow-x: hidden; // 修改为 hidden,内部容器处理滚动
    overflow-x: hidden;
    display: flex;
    flex-direction: column;
    max-width: calc(100% - 48px); // 减去头像和间距
    max-width: calc(100% - 56px);
    .text-box {
      padding: 12px 16px;
      border-radius: 12px;
      padding: 14px 20px;
      border-radius: 18px;
      font-size: 14px;
      line-height: 1.6;
      line-height: 1.7;
      word-break: break-word;
      max-width: 100%;
      width: fit-content;
      overflow-x: auto;
      transition: all 0.3s ease;
      position: relative;
      &::-webkit-scrollbar {
        height: 6px;
        height: 4px;
      }
      &::-webkit-scrollbar-thumb {
        background: #dcdfe6;
        border-radius: 3px;
        background: rgba(0, 85, 212, 0.25);
        border-radius: 2px;
      }
    }
  }
@@ -1007,13 +2568,26 @@
      align-items: flex-start;
    }
    .avatar {
      background-color: #409eff;
      background: $gradient-dark;
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.35);
    }
    .text-box {
      background-color: #fff;
      color: #303133;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
      background: #fff;
      color: #1a1a2e;
      box-shadow: $shadow-card;
      border: 1px solid rgba(0, 85, 212, 0.08);
      border-top-left-radius: 6px;
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 1px;
        background: linear-gradient(90deg, rgba(0, 85, 212, 0.15), transparent);
      }
    }
  }
@@ -1023,29 +2597,52 @@
      align-items: flex-end;
    }
    .avatar {
      background-color: #95d475;
      background: linear-gradient(145deg, #5a9fe0, #3d8bd4);
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
    }
    .text-box {
      background-color: #409eff;
      background: $gradient-dark;
      color: #fff;
      border-top-right-radius: 6px;
      box-shadow: 0 6px 24px rgba(0, 85, 212, 0.3);
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 1px;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
      }
    }
  }
}
@keyframes shimmer {
  0% {
    transform: translateX(-100%) rotate(45deg);
  }
  100% {
    transform: translateX(100%) rotate(45deg);
  }
}
.charts-wrapper {
  margin-top: 10px;
  margin-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  gap: 12px;
  overflow-x: auto;
  width: 100%;
  padding-bottom: 8px;
  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: #dcdfe6;
    background: linear-gradient(90deg, $light-blue, $secondary-blue);
    border-radius: 3px;
  }
}
@@ -1054,153 +2651,1159 @@
  width: 100%;
  min-width: 300px;
  height: 300px;
  background: #fff;
  border-radius: 8px;
  padding: 10px;
  border-radius: 12px;
  padding: 12px;
  margin-bottom: 12px;
}
.table-wrapper {
  margin-top: 10px;
  margin-top: 12px;
  background: #fff;
  border-radius: 8px;
  border-radius: 12px;
  overflow: hidden;
  overflow-x: auto;
  width: 100%;
  box-shadow: $shadow-card;
  border: 1px solid rgba(0, 122, 255, 0.06);
  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: #dcdfe6;
    background: linear-gradient(90deg, $light-blue, $secondary-blue);
    border-radius: 3px;
  }
  .el-table {
    min-width: 300px;
    --el-table-border-color: rgba(0, 122, 255, 0.08);
    --el-table-header-bg-color: $ice-white;
  }
}
.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-editor {
  :deep(.el-textarea__inner) {
    font-family: Consolas, Monaco, monospace;
    font-size: 12px;
    line-height: 1.55;
  }
}
.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: 16px;
  background-color: #fff;
  border-top: 1px solid #dcdfe6;
  padding: 18px 20px;
  background: linear-gradient(180deg, rgba(232, 242, 255, 0.95) 0%, #fff 100%);
  border-top: 1px solid rgba(0, 85, 212, 0.1);
  position: relative;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 20px;
    right: 20px;
    height: 1px;
    background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.15), transparent);
  }
  .input-actions {
  display: flex;
  gap: 12px;
  margin-bottom: 8px;
  align-items: center;
  .file-upload-trigger {
    display: inline-flex;
    display: flex;
    gap: 14px;
    margin-bottom: 12px;
    align-items: center;
    .file-upload-trigger {
      display: inline-flex;
      align-items: center;
    }
    :deep(.utility-action-btn) {
      position: relative;
      height: 34px;
      padding: 0 14px;
      border-radius: 999px;
      border: 1px solid rgba(92, 119, 255, 0.18);
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(236, 243, 255, 0.98));
      color: $primary-blue;
      font-weight: 600;
      box-shadow: 0 10px 20px rgba(0, 85, 212, 0.08);
      transition: all 0.25s ease;
      .el-icon {
        margin-right: 5px;
      }
      &:hover:not(.is-disabled) {
        color: #fff;
        border-color: transparent;
        background: linear-gradient(135deg, #1f6dff 0%, #6b38ef 100%);
        box-shadow: 0 14px 24px rgba(64, 90, 255, 0.2);
        transform: translateY(-1px);
      }
    }
    :deep(.stop-action-btn) {
      border-color: rgba(255, 99, 123, 0.18);
      color: #d33e5e;
      &:hover:not(.is-disabled) {
        background: linear-gradient(135deg, #f5536e 0%, #a33cff 100%);
      }
    }
  }
}
  .input-box {
    padding: 12px;
    padding: 16px;
    position: relative;
    background: #fff;
    border: 1px solid #dcdfe6;
    border-radius: 8px;
    margin: 0 16px 16px;
    transition: border-color 0.2s;
    border: 2px solid rgba(0, 85, 212, 0.12);
    border-radius: 16px;
    margin: 0 4px;
    transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    &:focus-within {
      border-color: #409eff;
      border-color: $primary-blue;
      box-shadow: 0 0 0 4px rgba(0, 85, 212, 0.12), $shadow-deep;
      transform: translateY(-2px);
      background: #fff;
    }
    .selected-file-list {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-bottom: 12px;
    }
    .selected-file-tag {
      display: flex;
      align-items: center;
      background: #f0f7ff;
      border: 1px solid #d9ecff;
      border-radius: 4px;
      padding: 4px 8px;
      margin-bottom: 8px;
      gap: 6px;
      background: linear-gradient(135deg, rgba(0, 85, 212, 0.1) 0%, rgba(0, 136, 232, 0.15) 100%);
      border: 1px solid rgba(0, 85, 212, 0.2);
      border-radius: 10px;
      padding: 8px 12px;
      gap: 10px;
      width: fit-content;
      max-width: 100%;
      animation: tagSlideIn 0.3s ease;
      @keyframes tagSlideIn {
        from {
          opacity: 0;
          transform: translateX(-10px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      .el-icon {
        color: #409eff;
        font-size: 14px;
        color: $primary-blue;
        font-size: 18px;
      }
      .file-name {
        font-size: 12px;
        color: #606266;
        font-size: 13px;
        color: $deep-blue;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        font-weight: 600;
      }
      .remove-file {
        cursor: pointer;
        color: #909399;
        transition: color 0.2s;
        color: $secondary-blue;
        transition: all 0.2s;
        padding: 4px;
        border-radius: 50%;
        &:hover {
          color: #f56c6c;
          color: #fff;
          background: rgba(245, 108, 108, 0.8);
          transform: scale(1.1) rotate(90deg);
        }
      }
    }
    :deep(.el-textarea__inner) {
      padding: 0;
      padding-bottom: 35px;
      border: none;
      box-shadow: none;
      background: transparent;
      font-family: inherit;
      font-size: 14px;
      line-height: 1.5;
      color: #303133;
      line-height: 1.6;
      color: #1a1a2e;
      &::placeholder {
        color: #c0c4cc;
        color: #7ab8ff;
      }
      &:focus {
        box-shadow: none;
      }
    }
    .send-btn {
      position: absolute;
      right: 12px;
      bottom: 12px;
      padding: 8px 16px;
      right: 16px;
      bottom: 16px;
      padding: 10px 22px;
      background: $gradient-dark;
      border: none;
      border-radius: 10px;
      font-weight: 600;
      font-size: 14px;
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
      transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
      overflow: hidden;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      letter-spacing: 0.3px;
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: -100%;
        width: 100%;
        height: 100%;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
        transition: left 0.5s;
      }
      &:hover:not(:disabled) {
        transform: translateY(-3px) scale(1.02);
        box-shadow: 0 10px 30px rgba(0, 85, 212, 0.5);
        &::before {
          left: 100%;
        }
      }
      &:active:not(:disabled) {
        transform: translateY(-1px) scale(0.98);
      }
      &:disabled {
        background: linear-gradient(145deg, #b0b0b0, #c5c5c5);
        box-shadow: none;
        cursor: not-allowed;
      }
      .el-icon {
        font-size: 15px;
        transform: translateY(-1px);
      }
    }
  }
}
.typing-indicator {
  display: flex;
  gap: 4px;
  padding: 8px 12px;
  gap: 5px;
  padding: 10px 14px;
  background: #fff;
  border-radius: 12px;
  border-radius: 14px;
  width: fit-content;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
  margin-top: 4px;
  box-shadow: $shadow-card;
  margin-top: 6px;
  border: 1px solid rgba(0, 122, 255, 0.06);
  border-top-left-radius: 4px;
  .dot {
    width: 6px;
    height: 6px;
    background-color: #909399;
    width: 7px;
    height: 7px;
    background: $secondary-blue;
    border-radius: 50%;
    animation: typing 1.4s infinite ease-in-out;
    &:nth-child(2) { animation-delay: 0.2s; }
    &:nth-child(3) { animation-delay: 0.4s; }
    &:nth-child(2) {
      animation-delay: 0.2s;
      background: $primary-blue;
    }
    &:nth-child(3) {
      animation-delay: 0.4s;
      background: $deep-blue;
    }
  }
}
@keyframes typing {
  0%, 80%, 100% { transform: scale(0); }
  40% { transform: scale(1); }
  0%, 80%, 100% {
    transform: scale(0.6);
    opacity: 0.4;
  }
  40% {
    transform: scale(1);
    opacity: 1;
  }
}
.code-block {
  background: #2d2d2d;
  color: #ccc;
  padding: 12px;
  border-radius: 6px;
  font-family: monospace;
  margin: 8px 0;
  background: linear-gradient(145deg, #1a1a2e, #16213e);
  color: #a8d8ff;
  padding: 14px;
  border-radius: 10px;
  font-family: 'Fira Code', 'Consolas', monospace;
  margin: 10px 0;
  overflow-x: auto;
  border: 1px solid rgba(90, 200, 250, 0.15);
  box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
  &.js-code {
    color: #f08d49;
    color: #5ac8fa;
  }
}
.chat-main {
  background:
    radial-gradient(circle at top left, rgba(46, 140, 224, 0.12) 0%, transparent 34%),
    linear-gradient(180deg, #fff 0%, #f7fbff 46%, #fff 100%);
}
.chat-hero {
  display: grid;
  grid-template-columns: 164px minmax(0, 1fr);
  gap: 18px;
  align-items: start;
  padding: 14px 18px 6px;
  &.compact {
    grid-template-columns: 122px minmax(0, 1fr);
    gap: 12px;
    padding: 8px 18px 2px;
  }
}
.assistant-stand {
  position: relative;
  min-height: 252px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  padding-top: 18px;
  overflow: hidden;
  &.compact {
    min-height: 176px;
    padding-top: 8px;
  }
  &.thinking {
    .assistant-halo {
      opacity: 1;
      transform: scale(1.08);
      filter: blur(8px);
    }
    .assistant-scan-ring {
      opacity: 1;
      animation-duration: 1.6s;
    }
    .assistant-orbit {
      opacity: 1;
    }
    .assistant-bot {
      transform: translateY(-4px) scale(1.02);
    }
    .assistant-bot-head {
      box-shadow: 0 0 30px rgba(80, 157, 255, 0.36);
    }
    .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-status {
      color: #6a3bee;
      box-shadow: 0 10px 22px rgba(106, 59, 238, 0.14);
    }
    .assistant-status-dot {
      background: #6a3bee;
      box-shadow: 0 0 12px rgba(106, 59, 238, 0.9);
      animation: thinkingDot 1s ease-in-out infinite;
    }
    .assistant-base-sm {
      box-shadow: 0 0 24px rgba(255, 93, 122, 0.48);
    }
  }
}
.assistant-halo {
  position: absolute;
  top: 22px;
  width: 130px;
  height: 130px;
  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%);
  filter: blur(6px);
  opacity: 0.82;
  transition: all 0.35s ease;
}
.assistant-scan-ring {
  position: absolute;
  top: 40px;
  width: 132px;
  height: 132px;
  border-radius: 50%;
  border: 1px solid rgba(90, 159, 224, 0.22);
  box-shadow: inset 0 0 16px rgba(255, 255, 255, 0.25);
  opacity: 0.55;
  animation: scanRing 4s linear infinite;
}
.assistant-orbit {
  position: absolute;
  top: 52px;
  width: 150px;
  height: 150px;
  border-radius: 50%;
  border: 1px dashed rgba(92, 135, 255, 0.22);
  opacity: 0.45;
}
.assistant-orbit-a {
  animation: orbitRotate 8s linear infinite;
}
.assistant-orbit-b {
  width: 118px;
  height: 118px;
  top: 68px;
  border-color: rgba(255, 108, 150, 0.22);
  animation: orbitRotateReverse 5.6s linear infinite;
}
.assistant-bot {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin-top: 12px;
  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%;
    transform: translateX(-50%);
    background: linear-gradient(135deg, #54bfff, #7a41ff);
    box-shadow: 0 0 14px rgba(84, 191, 255, 0.65);
  }
}
.assistant-bot-antenna-left {
  left: 36px;
  transform: rotate(-14deg);
}
.assistant-bot-antenna-right {
  right: 36px;
  transform: rotate(14deg);
}
.assistant-bot-head {
  position: relative;
  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);
}
.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 {
  position: absolute;
  left: 50%;
  bottom: 16px;
  width: 22px;
  height: 4px;
  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);
}
.assistant-status {
  position: relative;
  z-index: 1;
  margin-top: 14px;
  padding: 6px 12px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  color: $deep-blue;
  background: rgba(255, 255, 255, 0.95);
  border: 1px solid rgba(0, 85, 212, 0.12);
  box-shadow: 0 8px 20px rgba(0, 85, 212, 0.08);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.assistant-status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #2e8ce0;
  box-shadow: 0 0 10px rgba(46, 140, 224, 0.72);
}
.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;
}
.assistant-base-md {
  bottom: 6px;
  width: 88px;
  height: 20px;
  border-color: rgba(255, 93, 122, 0.34);
}
.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));
  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);
  }
}
@keyframes orbitRotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
@keyframes orbitRotateReverse {
  from {
    transform: rotate(360deg);
  }
  to {
    transform: rotate(0deg);
  }
}
@keyframes scanRing {
  0%, 100% {
    transform: scale(0.96);
    opacity: 0.42;
  }
  50% {
    transform: scale(1.04);
    opacity: 0.86;
  }
}
@keyframes thinkingDot {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.35);
  }
}
.welcome-card {
  position: relative;
  padding: 14px 14px 12px;
  border-radius: 16px;
  background:
    linear-gradient(#fff, #fff) padding-box,
    linear-gradient(135deg, rgba(255, 64, 96, 0.85), rgba(117, 65, 255, 0.9)) border-box;
  border: 1px solid transparent;
  box-shadow: 0 16px 36px rgba(0, 85, 212, 0.12);
  &.compact {
    padding: 10px 12px;
    border-radius: 12px;
    box-shadow: 0 8px 16px rgba(0, 85, 212, 0.07);
    .welcome-eyebrow {
      margin-bottom: 4px;
    }
    .welcome-title {
      font-size: 17px;
      line-height: 1.3;
      br {
        display: none;
      }
    }
    .welcome-desc {
      margin-top: 6px;
      font-size: 12px;
      line-height: 1.55;
    }
    .quick-prompt-list {
      margin-top: 10px;
      gap: 6px;
    }
    .quick-prompt-btn {
      padding: 8px 10px;
      font-size: 12px;
      border-radius: 7px;
    }
    .more-prompts-btn {
      margin-top: 8px;
      font-size: 12px;
    }
  }
}
.welcome-eyebrow {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2px;
  color: rgba(0, 85, 212, 0.58);
  margin-bottom: 8px;
}
.welcome-title {
  margin: 0;
  font-size: 26px;
  line-height: 1.2;
  font-weight: 800;
  color: #172033;
}
.welcome-desc {
  margin: 10px 0 0;
  font-size: 13px;
  line-height: 1.7;
  color: #5f6980;
}
.quick-prompt-list {
  display: grid;
  gap: 8px;
  margin-top: 14px;
}
.quick-prompt-btn {
  width: 100%;
  border: none;
  border-radius: 10px;
  padding: 11px 14px;
  text-align: left;
  font-size: 13px;
  font-weight: 600;
  color: #fff;
  cursor: pointer;
  background: linear-gradient(90deg, #ff4c55 0%, #7c38ef 100%);
  box-shadow: 0 12px 22px rgba(124, 56, 239, 0.18);
  transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 56%);
    pointer-events: none;
  }
  &::after {
    content: '';
    position: absolute;
    top: -120%;
    left: -30%;
    width: 45%;
    height: 260%;
    background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.3), transparent);
    transform: rotate(22deg);
    opacity: 0;
    transition: all 0.35s ease;
  }
  &:hover:not(:disabled) {
    transform: translateY(-2px) scale(1.01);
    box-shadow: 0 16px 28px rgba(124, 56, 239, 0.24);
    &::after {
      left: 100%;
      opacity: 1;
    }
  }
  &:disabled {
    cursor: not-allowed;
    opacity: 0.65;
  }
}
.more-prompts-btn {
  margin-top: 10px;
  padding: 0 12px;
  height: 32px;
  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-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-shadow: 0 10px 18px rgba(208, 65, 81, 0.08);
  transition: all 0.25s ease;
  &:hover {
    transform: translateY(-1px);
    background: linear-gradient(135deg, #ff5570 0%, #8a3df6 100%);
    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));
  }
}
.message-list {
  padding: 8px 18px 18px;
  gap: 16px;
  background: transparent;
}
.input-area {
  padding: 12px 18px 16px;
  background: #fff;
  border-top: none;
  &::before {
    display: none;
  }
  .input-box {
    padding: 14px 16px 16px;
    border: 1px solid rgba(123, 56, 239, 0.9);
    border-radius: 22px;
    margin: 0;
    transition: all 0.25s ease;
    box-shadow: 0 14px 34px rgba(0, 85, 212, 0.08);
    &:focus-within {
      border-color: #7c38ef;
      box-shadow: 0 0 0 3px rgba(124, 56, 239, 0.1), 0 18px 40px rgba(0, 85, 212, 0.12);
      transform: none;
    }
    :deep(.el-textarea__inner) {
      padding-right: 58px;
      padding-bottom: 0;
      min-height: 104px;
      &::placeholder {
        color: #a0a9bc;
      }
    }
    .send-btn {
      right: 25px;
      top: 50%;
      transform: translateY(-50%);
      width: 36px;
      min-width: 36px;
      height: 36px;
      padding: 0;
      background: linear-gradient(135deg, #ff5570 0%, #7a36f2 58%, #2d79ff 100%);
      border-radius: 50%;
      box-shadow: 0 12px 24px rgba(109, 50, 236, 0.24);
      transition: all 0.25s ease;
      gap: 0;
      &:hover:not(:disabled) {
        transform: translateY(calc(-50% - 1px)) scale(1.04);
        box-shadow: 0 16px 28px rgba(109, 50, 236, 0.3);
      }
      &:active:not(:disabled) {
        transform: translateY(-50%) scale(0.96);
      }
      .el-icon {
        margin: 0;
        font-size: 16px;
        transform: translate(0, -1px);
      }
    }
  }
}
@media (max-width: 767px) {
  .chat-hero {
    grid-template-columns: 1fr;
    gap: 10px;
    padding: 14px 14px 6px;
    &.compact {
      padding: 8px 14px 4px;
    }
  }
  .assistant-stand {
    min-height: 184px;
  }
  .welcome-card {
    padding: 12px 12px 10px;
  }
  .welcome-title {
    font-size: 21px;
  }
  .hero-dot-grid {
    grid-template-columns: repeat(12, 1fr);
    gap: 6px;
    padding: 0 14px 12px;
  }
  .message-list {
    padding: 8px 14px 14px;
  }
  .input-area {
    padding: 10px 14px 14px;
  }
  .input-area .input-actions {
    gap: 10px;
    flex-wrap: wrap;
  }
}
</style>