实现Agent对话,合格自动提交,不合格补充材料的能力

This commit is contained in:
wandering
2026-06-14 12:56:33 +08:00
parent 8dd91df3b9
commit 46305fdebb
68 changed files with 8914 additions and 1908 deletions

View File

@@ -0,0 +1,198 @@
# chat/ 模块说明
## 目录
- [架构概览](#架构概览)
- [模块职责](#模块职责)
- [消息类型](#消息类型)
- [数据流](#数据流)
- [状态管理](#状态管理)
- [注意事项](#注意事项)
---
## 架构概览
```
chat/
├── renderer.js # 底层 DOM 渲染工具
├── persistent.js # 持久消息(永久保留在聊天历史中)
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
chat.js # 入口文件,统一 re-export 所有公开函数
```
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
---
## 模块职责
### renderer.js
最底层渲染工具,不维护任何状态。提供:
- `getChatMessages()` — 获取 `#chat-messages` 容器
- `scrollToBottom()` — 滚动到底部
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
### persistent.js
管理永久保留在聊天历史中的消息:
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
- `addFileMessage(filename)` — 添加文件上传消息
- `setFileProcessing(filename)` — 文件处理中状态
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
- `setFileCached(filename)` — 文件使用缓存
- `setFileError(filename, errorMsg)` — 文件处理错误
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
### ephemeral.js
管理瞬态状态消息,同一时刻只显示最新一条:
- `showStatus(text, type)` — 显示/更新状态消息
- `clearStatus()` — 清除当前状态消息
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
### stream.js
管理 LLM 流式响应的气泡生命周期:
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
支持的阶段:
| phase | 说明 |
|-------|------|
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
| `reasoning` | 追加思考过程文本 |
| `chunk` | 追加正式回复文本 |
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
| `error` | 切换为 `error` 样式,显示错误信息 |
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
---
## 消息类型
### 持久消息
永久保留在聊天历史中,不会被自动清除:
- 用户输入的文字
- 文件上传记录及其处理状态
- LLM 流式响应的最终结果(`end` 阶段后固化)
- 系统通知(如"信息完整,可以提交"
- 错误消息
调用 `addChatMessage()``addFileMessage()` 创建。
### 瞬态消息
只显示最新一条,新状态覆盖旧状态:
- "正在分析文件..."
- "正在校验信息完整性..."
- "请输入登录账号:"
- "配置信息已完整,开始处理发票……"
- "收到补充文件,正在重新分析..."
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
---
## 数据流
```
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
├── import { showStatus, clearStatus } from './chat.js'
└── import { handleLLMStream } from './chat.js'
chat.js (re-export)
├── → chat/persistent.js ──→ chat/renderer.js
├── → chat/ephemeral.js ──→ chat/renderer.js
└── → chat/stream.js ──→ chat/renderer.js
```
- 外部模块统一从 `chat.js` 导入函数
- `chat.js` 只做 re-export不引入循环依赖
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
- `persistent.js` 额外依赖 `state.js``App.fileMessageMap`
---
## 状态管理
### ephemeralState (ephemeral.js)
```js
{
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
bubble: HTMLElement | null, // 状态气泡元素
}
```
- 初始为 `null`
- `showStatus` 首次调用时创建并记录引用
- 后续调用直接更新 `bubble.textContent``bubble.className`
- `clearStatus` 时移除 DOM 并重置为 `null`
### llmStreamState (stream.js)
```js
{
wrapper: HTMLElement | null, // 流式消息 wrapper
bubble: HTMLElement | null, // 流式气泡
textContent: HTMLElement | null, // 正式文本容器
accumulated: string, // 累积的正式文本
reasoningContent: HTMLElement | null, // 思考过程容器
reasoningAccumulated: string, // 累积的思考文本
}
```
- `start` 阶段创建并记录引用
- `chunk` / `reasoning` 阶段追加文本
- `end` / `error` 阶段重置为 `null`DOM 保留在历史中)
### App.fileMessageMap (state.js)
```js
Map<string, { wrapper, bubble, nameRow, detailRow }>
```
- 文件名 → DOM 元素映射
- `addFileMessage` 创建时写入
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
---
## 注意事项
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。

View File

@@ -0,0 +1,62 @@
/**
* 瞬态消息管理器
*
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的瞬态消息 DOM 引用
*/
let ephemeralState = {
wrapper: null,
bubble: null,
};
/**
* 显示瞬态状态消息
*
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
*
* @param {string} text - 状态文本
* @param {string} [type='processing'] - 消息类型
*/
export function showStatus(text, type) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (ephemeralState.bubble) {
ephemeralState.bubble.textContent = escapeHtml(text);
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
scrollToBottom();
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
`;
chatMessages.appendChild(wrapper);
scrollToBottom();
ephemeralState = {
wrapper,
bubble: wrapper.querySelector('.chat-bubble'),
};
}
/**
* 清除瞬态状态消息
*/
export function clearStatus() {
if (ephemeralState.wrapper) {
ephemeralState.wrapper.remove();
}
ephemeralState = {
wrapper: null,
bubble: null,
};
}

View File

@@ -0,0 +1,246 @@
/**
* 持久消息管理器
*
* 持久消息永久保留在聊天历史中,包括:
* - 普通聊天消息
* - 文件上传消息及其状态
* - 加载指示器
*/
import { App } from '../state.js';
import { escapeHtml } from '../utils.js';
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
// ================================================================
// 普通聊天消息
// ================================================================
/**
* 添加聊天消息
*
* @param {string} text - 消息内容
* @param {string} [type='processing'] - 消息类型
* @param {boolean} [isUser=false] - 是否为用户消息
*/
export function addChatMessage(text, type, isUser) {
const wrapper = createChatBubble(text, type, isUser);
appendMessage(wrapper);
}
// ================================================================
// 加载指示器
// ================================================================
/**
* 显示加载指示器(三点动画)
*/
export function addTypingIndicator() {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const indicator = document.createElement('div');
indicator.className = 'chat-message';
indicator.id = 'typing-indicator';
indicator.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble processing">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
chatMessages.appendChild(indicator);
scrollToBottom();
}
/**
* 移除加载指示器
*/
export function removeTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (indicator) indicator.remove();
}
// ================================================================
// 文件消息管理
// ================================================================
/**
* 添加文件消息(上传文件时调用)
*
* @param {string} filename - 文件名
*/
export function addFileMessage(filename) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const wrapper = document.createElement('div');
wrapper.className = 'chat-message user file-message';
wrapper.dataset.file = filename;
const bubble = document.createElement('div');
bubble.className = 'chat-bubble file-bubble';
const nameRow = document.createElement('div');
nameRow.className = 'file-name-row';
nameRow.innerHTML = `
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
<span class="file-name">${escapeHtml(filename)}</span>
<span class="file-status" style="display:none"></span>
`;
const detailRow = document.createElement('div');
detailRow.className = 'file-detail';
detailRow.style.display = 'none';
bubble.appendChild(nameRow);
bubble.appendChild(detailRow);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
}
/**
* 设置文件处理中状态
*
* @param {string} filename - 文件名
*/
export function setFileProcessing(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-done', 'file-error');
bubble.classList.add('file-processing');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline-flex';
statusEl.innerHTML = `
<span class="file-typing-indicator">
<span></span><span></span><span></span>
</span>
`;
detailRow.style.display = 'none';
}
/**
* 更新文件消息为完成状态
*
* @param {string} filename - 文件名
* @param {Object} summary - 提取摘要对象
*/
export function setFileDone(filename, summary) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
const labelMap = {
card_date: '刷卡日期',
person_id: '人员编号',
person_name: '人员姓名',
invoice_type_label: '类型',
invoice_number: '发票号码',
invoice_date: '开票日期',
ride_date: '乘车日期',
departure: '出发站',
arrival: '到达站',
seat_class: '座位等级',
train_no: '车次',
total_amount: '金额',
amount: '金额',
seller_name: '销售方',
buyer_name: '购买方',
goods_name: '货物/服务',
remark: '备注',
card_no: '卡号',
card_amount: '刷卡金额',
pay_date: '支付日期',
pay_time: '支付时间',
pay_channel: '支付渠道',
transaction_no: '交易单号',
merchant_name: '商户名称',
order_no: '订单号',
project_name: '项目名称',
purpose: '出差事由',
start_date: '开始日期',
end_date: '结束日期',
person_info: '随行人员',
travel_purpose: '出差事由',
travel_from: '出发地',
travel_to: '目的地',
travel_start: '出发日期',
travel_end: '返回日期',
departure_place: '出发地',
arrival_place: '目的地',
hotel_name: '酒店名称',
checkin_date: '入住日期',
checkout_date: '退房日期',
room_type: '房型',
days: '天数',
attachments: '附件',
subsidy_list: '补助清单',
};
let lines = [];
for (const [key, value] of Object.entries(summary)) {
const label = labelMap[key] || key;
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
}
detailRow.innerHTML = lines.join('<br>');
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为缓存状态
*
* @param {string} filename - 文件名
*/
export function setFileCached(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为错误状态
*
* @param {string} filename - 文件名
* @param {string} [errorMsg] - 错误信息
*/
export function setFileError(filename, errorMsg) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-error');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

View File

@@ -0,0 +1,64 @@
/**
* 聊天消息渲染器
*
* 提供通用的气泡创建、DOM 操作和滚动功能。
*/
import { escapeHtml } from '../utils.js';
/**
* 获取聊天消息容器
*/
export function getChatMessages() {
return document.getElementById('chat-messages');
}
/**
* 滚动聊天容器到底部
*/
export function scrollToBottom() {
const chatMessages = getChatMessages();
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
/**
* 创建聊天消息气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型(影响气泡样式)
* @param {boolean} isUser - 是否为用户消息
* @returns {HTMLElement} wrapper 元素
*/
export function createChatBubble(text, type, isUser) {
const wrapper = document.createElement('div');
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
`;
return wrapper;
}
/**
* 创建带系统头像的气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型
* @returns {HTMLElement} wrapper 元素
*/
export function createSystemBubble(text, type) {
return createChatBubble(text, type, false);
}
/**
* 将消息追加到聊天容器
*
* @param {HTMLElement} wrapper - 消息 wrapper 元素
*/
export function appendMessage(wrapper) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
chatMessages.appendChild(wrapper);
scrollToBottom();
}

View File

@@ -0,0 +1,199 @@
/**
* LLM 流式聊天气泡管理器
*
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的 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;
scrollToBottom();
}
/**
* 追加思考过程片段reasoning 阶段)
*/
function _appendLLMStreamReasoning(text) {
if (!llmStreamState.reasoningContent) return;
llmStreamState.reasoningAccumulated += text;
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
scrollToBottom();
}
/**
* 关闭流式气泡end 阶段)— 气泡保留在聊天历史中作为持久消息
*/
function _closeLLMStreamBubble(label) {
if (!llmStreamState.bubble) return;
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;
}
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: '',
};
}