Files
Auto-Finance/src/web/static/js/chat/stream.js
T
2026-08-31 18:25:35 +08:00

445 lines
13 KiB
JavaScript

/**
* 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);
}