/** * LLM 流式聊天气泡管理器 * * 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。 */ import { escapeHtml } from '../utils.js'; import { getChatMessages, scrollToBottom } from './renderer.js'; /** * 判断用户是否在聊天底部附近(阈值 150px),用于流式传输时决定是否自动滚动 */ function _isNearBottom() { const el = getChatMessages(); if (!el) return true; return el.scrollHeight - el.scrollTop - el.clientHeight < 150; } /** * 当前活跃的 LLM 流式气泡状态 */ let llmStreamState = { wrapper: null, bubble: null, textContent: null, accumulated: '', reasoningContent: null, reasoningAccumulated: '', }; /** * 处理 LLM 流式事件 * * @param {Object} msg - SSE 传来的 llm_stream 事件对象 */ export function handleLLMStream(msg) { switch (msg.phase) { case 'start': _createLLMStreamBubble(msg.label || '正在分析中...'); break; case 'reasoning': _appendLLMStreamReasoning(msg.text || ''); break; case 'chunk': _appendLLMStreamChunk(msg.text || ''); break; case 'end': _closeLLMStreamBubble(msg.label || '分析完成'); break; case 'error': _errorLLMStreamBubble(msg.error || '分析失败'); break; } } /** * 创建 LLM 流式气泡(start 阶段) */ function _createLLMStreamBubble(label) { const chatMessages = getChatMessages(); if (!chatMessages) return; if (llmStreamState.wrapper) { _closeLLMStreamBubbleSilent(); } const wrapper = document.createElement('div'); wrapper.className = 'chat-message'; const bubble = document.createElement('div'); bubble.className = 'chat-bubble processing llm-stream-bubble'; const labelEl = document.createElement('div'); labelEl.className = 'llm-stream-label'; labelEl.textContent = label; const reasoningSection = document.createElement('details'); reasoningSection.className = 'llm-reasoning-section'; reasoningSection.open = true; const reasoningSummary = document.createElement('summary'); reasoningSummary.className = 'llm-reasoning-summary'; reasoningSummary.textContent = '思考过程(点击折叠)'; const reasoningText = document.createElement('div'); reasoningText.className = 'llm-reasoning-text'; reasoningText.textContent = ''; reasoningSection.appendChild(reasoningSummary); reasoningSection.appendChild(reasoningText); const textEl = document.createElement('div'); textEl.className = 'llm-stream-text'; textEl.textContent = ''; bubble.appendChild(labelEl); bubble.appendChild(reasoningSection); bubble.appendChild(textEl); wrapper.appendChild(bubble); chatMessages.appendChild(wrapper); scrollToBottom(); llmStreamState = { wrapper, bubble, textContent: textEl, accumulated: '', reasoningContent: reasoningText, reasoningAccumulated: '', }; } /** * 追加流式文本片段(chunk 阶段) */ function _appendLLMStreamChunk(text) { if (!llmStreamState.textContent) return; llmStreamState.accumulated += text; llmStreamState.textContent.textContent = llmStreamState.accumulated; if (_isNearBottom()) { scrollToBottom(); } } /** * 追加思考过程片段(reasoning 阶段) */ function _appendLLMStreamReasoning(text) { if (!llmStreamState.reasoningContent) return; llmStreamState.reasoningAccumulated += text; llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated; if (_isNearBottom()) { scrollToBottom(); } } /** * 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息。 * 如果累积内容是提取结果 JSON,则渲染为美观的摘要卡片。 */ function _closeLLMStreamBubble(label) { if (!llmStreamState.bubble) return; const accumulated = llmStreamState.accumulated; llmStreamState.bubble.classList.remove('processing'); llmStreamState.bubble.classList.add('done'); const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label'); if (labelEl) { labelEl.textContent = label; } const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section'); if (reasoningSection) { reasoningSection.open = false; } // 尝试将累积文本解析为提取结果 JSON,渲染为摘要卡片 const textEl = llmStreamState.bubble.querySelector('.llm-stream-text'); if (textEl && accumulated) { const parsed = _tryParseExtractionJson(accumulated); if (parsed) { textEl.innerHTML = ''; textEl.appendChild(_buildExtractionSummary(parsed)); } } scrollToBottom(); llmStreamState = { wrapper: null, bubble: null, textContent: null, accumulated: '', reasoningContent: null, reasoningAccumulated: '', }; } /** * 静默关闭流式气泡(不改变样式,用于 start 前清理) */ function _closeLLMStreamBubbleSilent() { if (llmStreamState.wrapper) { llmStreamState.wrapper.remove(); } llmStreamState = { wrapper: null, bubble: null, textContent: null, accumulated: '', reasoningContent: null, reasoningAccumulated: '', }; } /** * 错误状态(error 阶段) */ function _errorLLMStreamBubble(errorMsg) { if (!llmStreamState.bubble) return; llmStreamState.bubble.classList.remove('processing'); llmStreamState.bubble.classList.add('error'); const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label'); if (labelEl) { labelEl.textContent = '分析失败'; } if (llmStreamState.textContent) { llmStreamState.textContent.textContent = escapeHtml(errorMsg); } llmStreamState = { wrapper: null, bubble: null, textContent: null, accumulated: '', reasoningContent: null, reasoningAccumulated: '', }; } /** * 尝试将 LLM 输出文本解析为提取结果 JSON * 返回解析后的对象,或 null(非提取结果 JSON) */ function _tryParseExtractionJson(text) { try { const trimmed = text.trim(); if (!trimmed.startsWith('{')) return null; const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === 'object' && (parsed.basic_info || parsed.reimbursement_details)) { return parsed; } return null; } catch { return null; } } /** * 将提取结果 JSON 渲染为美观的摘要卡片 DOM 元素 * * @param {Object} data - 提取结果 JSON * @returns {HTMLElement} */ function _buildExtractionSummary(data) { const container = document.createElement('div'); container.className = 'extraction-summary'; // 全局状态 const statusSection = _summarySection('状态'); const submitOk = data.can_submit; _summaryRow(statusSection, '可提交', submitOk ? '是' : '否'); if (data.suggestion) { _summaryRow(statusSection, '建议', data.suggestion); } container.appendChild(statusSection); // 判断类型:差旅 vs 普通 const isTravel = !!(data.basic_info?.travel_purpose || data.reimbursement_details?.transport_fee); // 基本信息 if (data.basic_info) { const bi = data.basic_info; const section = _summarySection('基本信息'); if (isTravel) { _summaryRow(section, '出差事由', bi.travel_purpose || ''); _summaryRow(section, '出差地点', bi.travel_location || ''); const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || ''); _summaryRow(section, '出差日期', dates); } else { _summaryRow(section, '报销说明', bi.reimbursement_description || ''); } container.appendChild(section); } // 报销汇总(普通模式) if (!isTravel && data.reimbursement_details) { const rd = data.reimbursement_details; const section = _summarySection('报销汇总'); _summaryRow(section, '发票总份数', rd.total_invoices != null ? String(rd.total_invoices) : ''); _summaryRow(section, '总金额', rd.total_amount != null ? `¥${rd.total_amount.toFixed(2)}` : ''); container.appendChild(section); } // 交通费(差旅模式) if (isTravel && data.reimbursement_details?.transport_fee?.length) { const section = _summarySection('交通费'); const table = _summaryTable(['日期', '出发', '到达', '金额', '备注']); for (const item of data.reimbursement_details.transport_fee) { _summaryTableRow(table, [ item.start_date || '', item.departure_place || '', item.arrival_place || '', item.amount != null ? `¥${item.amount.toFixed(2)}` : '', item.remark || '', ]); } section.appendChild(table); container.appendChild(section); } // 住宿费(差旅模式) if (isTravel && data.reimbursement_details?.hotel_fee?.length) { const section = _summarySection('住宿费'); const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']); for (const item of data.reimbursement_details.hotel_fee) { _summaryTableRow(table, [ item.checkin_date || '', item.checkout_date || '', item.days != null ? String(item.days) : '', item.invoice_amount != null ? `¥${item.invoice_amount.toFixed(2)}` : '', item.reimburse_amount != null ? `¥${item.reimburse_amount.toFixed(2)}` : '', item.remark || '', ]); } section.appendChild(table); container.appendChild(section); } // 会议/培训费(差旅模式) if (isTravel && data.reimbursement_details?.conference_fee?.length) { const section = _summarySection('会议/培训费'); const table = _summaryTable(['日期', '金额', '备注']); for (const item of data.reimbursement_details.conference_fee) { _summaryTableRow(table, [ item.start_date || '', item.amount != null ? `¥${item.amount.toFixed(2)}` : '', item.remark || '', ]); } section.appendChild(table); container.appendChild(section); } // 支付记录 if (data.payment_methods?.length) { const section = _summarySection('支付记录'); const table = _summaryTable(['日期', '金额', '商户', '备注']); for (const item of data.payment_methods) { _summaryTableRow(table, [ item.card_date || '', item.card_amount != null ? `¥${item.card_amount.toFixed(2)}` : '', item.merchant || '', item.remark || '', ]); } section.appendChild(table); container.appendChild(section); } // 补助(差旅模式) if (isTravel && data.subsidy_list?.length) { const section = _summarySection('补助'); const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']); for (const item of data.subsidy_list) { _summaryTableRow(table, [ item.person_name || '', item.start_date || '', item.end_date || '', item.days != null ? String(item.days) : '', ]); } section.appendChild(table); container.appendChild(section); } // 附件 if (data.attachments?.length) { const section = _summarySection('附件'); const list = document.createElement('ul'); list.className = 'summary-attachment-list'; for (const item of data.attachments) { const li = document.createElement('li'); li.className = `summary-attachment-item summary-attachment-${item.attachment_type || 'other'}`; li.textContent = `${item.attachment_desc || item.filename || ''}`; list.appendChild(li); } section.appendChild(list); container.appendChild(section); } return container; } /* ---- 提取摘要卡片构建辅助函数 ---- */ function _summarySection(title) { const section = document.createElement('div'); section.className = 'summary-section'; const titleEl = document.createElement('div'); titleEl.className = 'summary-section-title'; titleEl.textContent = title; section.appendChild(titleEl); return section; } function _summaryRow(section, label, value) { if (!value) return; const row = document.createElement('div'); row.className = 'summary-row'; const labelEl = document.createElement('span'); labelEl.className = 'summary-label'; labelEl.textContent = label; const valueEl = document.createElement('span'); valueEl.className = 'summary-value'; valueEl.textContent = value; row.appendChild(labelEl); row.appendChild(valueEl); section.appendChild(row); } function _summaryTable(headers) { const table = document.createElement('table'); table.className = 'summary-table'; const thead = document.createElement('thead'); const tr = document.createElement('tr'); for (const h of headers) { const th = document.createElement('th'); th.textContent = h; tr.appendChild(th); } thead.appendChild(tr); table.appendChild(thead); const tbody = document.createElement('tbody'); table.appendChild(tbody); return table; } function _summaryTableRow(table, cells) { const tbody = table.querySelector('tbody'); if (!tbody) return; const tr = document.createElement('tr'); for (const cell of cells) { const td = document.createElement('td'); td.textContent = cell; tr.appendChild(td); } tbody.appendChild(tr); }