实现Agent对话,合格自动提交,不合格补充材料的能力
This commit is contained in:
198
src/web/static/js/chat/README.md
Normal file
198
src/web/static/js/chat/README.md
Normal 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/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
|
||||
62
src/web/static/js/chat/ephemeral.js
Normal file
62
src/web/static/js/chat/ephemeral.js
Normal 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,
|
||||
};
|
||||
}
|
||||
246
src/web/static/js/chat/persistent.js
Normal file
246
src/web/static/js/chat/persistent.js
Normal 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' });
|
||||
}
|
||||
64
src/web/static/js/chat/renderer.js
Normal file
64
src/web/static/js/chat/renderer.js
Normal 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();
|
||||
}
|
||||
199
src/web/static/js/chat/stream.js
Normal file
199
src/web/static/js/chat/stream.js
Normal 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: '',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user