@bolloon/bolloon-agent 0.3.10 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-entry.js +94 -0
- package/dist/electron/config.js +9 -14
- package/dist/electron/dialogs.js +16 -53
- package/dist/electron/first-run.js +24 -65
- package/dist/electron/ipc.js +10 -14
- package/dist/electron/logger.js +7 -44
- package/dist/electron/main.js +42 -45
- package/dist/electron/menu.js +13 -18
- package/dist/electron/paths.js +12 -54
- package/dist/electron/server.js +18 -57
- package/dist/electron/tray.js +15 -53
- package/dist/electron/window.js +22 -61
- package/dist/electron-preload.js +16 -19
- package/dist/electron.js +1 -4
- package/dist/index.js +63 -3
- package/dist/utils/auto-update.js +12 -51
- package/dist/web/client.js +4133 -4656
- package/dist/web/components/p2p/P2PModal.js +188 -0
- package/dist/web/components/p2p/index.js +276 -234
- package/dist/web/components/p2p/p2p-modal.js +664 -0
- package/dist/web/components/p2p/p2p-tools.js +248 -0
- package/dist/web/ui/message-renderer.js +535 -396
- package/dist/web/ui/step-timeline.js +371 -272
- package/package.json +2 -2
|
@@ -1,433 +1,572 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* message-renderer.ts — 对话显示 UI (2026-06-15 从 client.js 拆出, .js → .ts)
|
|
3
|
+
*
|
|
4
|
+
* 职责 (单一):
|
|
5
|
+
* - 把 user / AI 内容渲染成 DOM 气泡 (含 marked.parse 渲染)
|
|
6
|
+
* - 处理流式 token 累积 (textNode 增量, O(1), 不重排)
|
|
7
|
+
* - finalize 流式消息为正式 AI 气泡
|
|
8
|
+
* - 折叠 think / environment_details 块
|
|
9
|
+
* - 复制 / 重新回答 / 蒸馏为判断 按钮
|
|
10
|
+
* - 挂载 step-timeline (2026-06-15) 步骤状态条到每条 AI 消息内
|
|
11
|
+
*
|
|
12
|
+
* 状态 (本模块私有):
|
|
13
|
+
* - streamingMessageEl / streamingTextNode / streamingText
|
|
14
|
+
* - lastUserCommand / lastAiContent (去重)
|
|
15
|
+
*
|
|
16
|
+
* 依赖 (import):
|
|
17
|
+
* - 浏览器 API (document, marked, fetch, navigator)
|
|
18
|
+
* - ./step-timeline (4 状态步骤条模块)
|
|
19
|
+
*
|
|
20
|
+
* 不依赖 (零业务 import, 防循环):
|
|
21
|
+
* - 不 import client.js
|
|
22
|
+
* - 不 import 任何业务模块
|
|
23
|
+
*
|
|
24
|
+
* 输入 (从 client.js 调用):
|
|
25
|
+
* - addMessage(content, type, save, container, usedIds, ctx)
|
|
26
|
+
* - handleStreamTokenEvent({ streamType, content }, ctx)
|
|
27
|
+
* - finalizeTimelineAsMessage(ctx)
|
|
28
|
+
* - handleStepEvent({ type: 'step_start'|'step_done'|'step_error', ... }, ctx)
|
|
29
|
+
* - escapeHtml(s)
|
|
30
|
+
* - getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl)
|
|
31
|
+
*
|
|
32
|
+
* 输出 (DOM):
|
|
33
|
+
* - .message / .bubble / .think-container / .env-container
|
|
34
|
+
* - .message-streaming (流式期间, finalize 时移除)
|
|
35
|
+
* - .message-actions (复制/重新回答/蒸馏按钮)
|
|
36
|
+
* - .used-judgments-link (P0.5 反向引用)
|
|
37
|
+
* - .step-timeline (气泡内步骤状态条, 由 ./step-timeline 渲染)
|
|
38
|
+
*/
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// 类型定义
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
import { mountStepTimeline, pushStepToTimeline, migrateStepTimeline, getStepTimeline, } from './step-timeline.js';
|
|
43
|
+
// 2026-07-01 (v0.2.6): 共享后端切 LLM 输出. 消除 <invoke>/<function_calls>/<tool_call>
|
|
44
|
+
// 等各种 LLM 格式在前端气泡里出现的 bug.
|
|
45
|
+
import { segmentChatReply } from '../../agents/chat-segmenter.js';
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// 模块私有状态
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
8
49
|
let streamingMessageEl = null;
|
|
9
50
|
let streamingTextNode = null;
|
|
10
|
-
let streamingText =
|
|
11
|
-
let lastUserCommand =
|
|
12
|
-
let lastAiContent =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
51
|
+
let streamingText = '';
|
|
52
|
+
let lastUserCommand = '';
|
|
53
|
+
let lastAiContent = '';
|
|
54
|
+
// 2026-07-20: Bug 1 — 非流式模式 step 事件缓冲, 按 channelId 分组
|
|
55
|
+
const stepEventBuffer = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* 2026-06-17: 流式状态查询 — 客户端用它在收到 server `ai` 事件时判断是否需要跳过 addMessage,
|
|
58
|
+
* 避免和后续 `done` → finalizeTimelineAsMessage 产生双气泡.
|
|
59
|
+
* 流式过程中 (streamingText > 0) server 推 `ai(content=fullResponse)` 时跳过,
|
|
60
|
+
* 真正渲染走 `done` 触发的 finalizeTimelineAsMessage.
|
|
61
|
+
*/
|
|
62
|
+
export function hasStreamingText() {
|
|
63
|
+
return streamingText.length > 0;
|
|
16
64
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
65
|
+
// 2026-07-06: SSE 重连恢复用 — 把 streamingText 替换成 server 给的 fullContent
|
|
66
|
+
// 然后 caller 调 finalizeTimelineAsMessage 把它落定为正式气泡
|
|
67
|
+
export function replaceStreamingText(fullContent) {
|
|
68
|
+
if (!streamingTextNode || !streamingMessageEl) {
|
|
69
|
+
// 还没启动流式元素 — 不动, 让 caller 自己 addMessage
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
streamingTextNode.nodeValue = String(fullContent || '');
|
|
73
|
+
streamingText = String(fullContent || '');
|
|
23
74
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
// 2026-07-06: SSE 重连时 server 说"还在生成", 把现有的 streamingMessageEl 填上 partialText
|
|
76
|
+
// 让用户看到 AI 在生成中的状态, 不会卡死
|
|
77
|
+
export function injectRecoveredText(partialText, ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
|
|
78
|
+
if (streamingMessageEl && streamingTextNode) {
|
|
79
|
+
streamingTextNode.nodeValue = String(partialText || '');
|
|
80
|
+
streamingText = String(partialText || '');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// 还没流式元素 — 创建一个空流式容器, 然后把 partialText 灌进去, 用户看到"AI 在思考"
|
|
84
|
+
handleStreamTokenEvent({
|
|
85
|
+
type: 'token',
|
|
86
|
+
streamType: 'token',
|
|
87
|
+
content: String(partialText || ''),
|
|
88
|
+
delta: String(partialText || ''),
|
|
89
|
+
}, ctx);
|
|
39
90
|
}
|
|
91
|
+
// 滚动限频 (60ms 16fps, 减 reflow)
|
|
40
92
|
let scrollToBottomTimer = null;
|
|
41
93
|
function scheduleScrollToBottom(container) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
scrollToBottomTimer =
|
|
47
|
-
|
|
94
|
+
if (!container)
|
|
95
|
+
return;
|
|
96
|
+
if (scrollToBottomTimer)
|
|
97
|
+
return;
|
|
98
|
+
scrollToBottomTimer = setTimeout(() => {
|
|
99
|
+
container.scrollTop = container.scrollHeight;
|
|
100
|
+
scrollToBottomTimer = null;
|
|
101
|
+
}, 60);
|
|
48
102
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// 容器选择 (主入口或 per-channel 容器)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
export function getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl) {
|
|
107
|
+
if (currentChannelId && messagesContainers.get(currentChannelId)) {
|
|
108
|
+
return messagesContainers.get(currentChannelId) || null;
|
|
109
|
+
}
|
|
110
|
+
return messagesEl;
|
|
54
111
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
})[c]);
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// HTML escape (供 think / env 折叠块用)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
export function escapeHtml(s) {
|
|
116
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
117
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
118
|
+
}[c]));
|
|
63
119
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 主入口: 渲染一条消息气泡 (user / ai / system)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
export function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null },
|
|
124
|
+
// 2026-07-15 修 Bug 2: 历史消息恢复时传历史 timestamp, 不传 = 用"现在" (新消息默认值, 防破坏 LLM 流式事件链).
|
|
125
|
+
timestamp) {
|
|
126
|
+
const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
|
|
127
|
+
const messagesContainers = ctx.messagesContainers || new Map();
|
|
128
|
+
const currentChannelId = ctx.currentChannelId;
|
|
129
|
+
const msgContainer = container || getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
|
|
130
|
+
// 内存保护: 单个 channel 容器超过 200 条, 旧会话加载时淘汰最旧
|
|
131
|
+
if (!save && msgContainer && msgContainer.children.length > 200) {
|
|
132
|
+
const toRemove = msgContainer.children.length - 200;
|
|
133
|
+
for (let i = 0; i < toRemove; i++) {
|
|
134
|
+
const first = msgContainer.firstElementChild;
|
|
135
|
+
if (first)
|
|
136
|
+
msgContainer.removeChild(first);
|
|
137
|
+
}
|
|
74
138
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
139
|
+
// 去重 (save=true 时)
|
|
140
|
+
if (save) {
|
|
141
|
+
const lastContent = type === 'user' ? lastUserCommand : lastAiContent;
|
|
142
|
+
if (lastContent && content === lastContent) {
|
|
143
|
+
console.log(`[addMessage] 跳过重复的 ${type} 消息`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (type === 'user')
|
|
147
|
+
lastUserCommand = content;
|
|
148
|
+
else
|
|
149
|
+
lastAiContent = content;
|
|
81
150
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
const div = document.createElement('div');
|
|
152
|
+
div.className = `message message-${type}`;
|
|
153
|
+
// 2026-07-06: 非流式模式 — AI 完整响应含 <think>...</think> + 实际回复 + <final gen>
|
|
154
|
+
// 统一清洗: 去 think 块 (LLM 思考过程不渲染), 取 <final gen> 之前内容作为实际回复
|
|
155
|
+
let cleanContent = content;
|
|
156
|
+
if (type === 'ai') {
|
|
157
|
+
cleanContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/g, '');
|
|
158
|
+
const finalGenIdx = cleanContent.indexOf('<final gen>');
|
|
159
|
+
if (finalGenIdx >= 0) {
|
|
160
|
+
cleanContent = cleanContent.substring(0, finalGenIdx).trim();
|
|
161
|
+
}
|
|
93
162
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (seg.type === "think" && seg.content) {
|
|
104
|
-
thinkContainer = buildThinkContainer(seg.content);
|
|
105
|
-
div.appendChild(thinkContainer);
|
|
106
|
-
renderedAny = true;
|
|
107
|
-
} else if (seg.type === "env_details" && seg.content) {
|
|
108
|
-
div.appendChild(buildEnvContainer(seg.content));
|
|
109
|
-
renderedAny = true;
|
|
110
|
-
} else if (seg.type === "text" && seg.content) {
|
|
111
|
-
if (thinkContainer) div.appendChild(thinkContainer);
|
|
112
|
-
thinkContainer = null;
|
|
113
|
-
div.appendChild(buildBubble(seg.content, type));
|
|
114
|
-
renderedAny = true;
|
|
115
|
-
} else if (seg.type === "final" && seg.content) {
|
|
116
|
-
if (thinkContainer) div.appendChild(thinkContainer);
|
|
117
|
-
thinkContainer = null;
|
|
118
|
-
const finalEl = buildBubble(seg.content, type);
|
|
119
|
-
finalEl.classList.add("bubble-final");
|
|
120
|
-
div.appendChild(finalEl);
|
|
121
|
-
renderedAny = true;
|
|
122
|
-
} else if (seg.type === "tool_call" && seg.tool) {
|
|
123
|
-
if (ctx && ctx.toolCallCallback) {
|
|
124
|
-
ctx.toolCallCallback(seg.tool, div);
|
|
125
|
-
}
|
|
126
|
-
renderedAny = true;
|
|
163
|
+
// 2026-07-01 (v0.2.6 前后端分离): 改用 chat-segmenter 纯函数切 LLM 输出.
|
|
164
|
+
// 之前 8 行正则漏 minimax <invoke> / Qwen function_calls / <tool_call> / {tool:..}.
|
|
165
|
+
// 单一来源: src/agents/chat-segmenter.ts (server + client 共享).
|
|
166
|
+
// knownToolNames: 用 ctx 传入的注册表, fallback 空集 (不识别 tool_call 就 strip 不显示).
|
|
167
|
+
const knownToolNames = (ctx && ctx.knownToolNames) || new Set();
|
|
168
|
+
const segments = segmentChatReply(cleanContent, { knownToolNames });
|
|
169
|
+
// 没有可显示的 segment, 不上屏
|
|
170
|
+
if (segments.length === 0) {
|
|
171
|
+
return;
|
|
127
172
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
// 渲染各 segment (按 type 走不同容器)
|
|
174
|
+
let thinkContainer = null;
|
|
175
|
+
let renderedAny = false;
|
|
176
|
+
for (const seg of segments) {
|
|
177
|
+
if (seg.type === 'think' && seg.content) {
|
|
178
|
+
thinkContainer = buildThinkContainer(seg.content);
|
|
179
|
+
div.appendChild(thinkContainer);
|
|
180
|
+
renderedAny = true;
|
|
181
|
+
}
|
|
182
|
+
else if (seg.type === 'env_details' && seg.content) {
|
|
183
|
+
div.appendChild(buildEnvContainer(seg.content));
|
|
184
|
+
renderedAny = true;
|
|
185
|
+
}
|
|
186
|
+
else if (seg.type === 'text' && seg.content) {
|
|
187
|
+
if (thinkContainer)
|
|
188
|
+
div.appendChild(thinkContainer);
|
|
189
|
+
thinkContainer = null;
|
|
190
|
+
div.appendChild(buildBubble(seg.content, type));
|
|
191
|
+
renderedAny = true;
|
|
192
|
+
}
|
|
193
|
+
else if (seg.type === 'final' && seg.content) {
|
|
194
|
+
if (thinkContainer)
|
|
195
|
+
div.appendChild(thinkContainer);
|
|
196
|
+
thinkContainer = null;
|
|
197
|
+
// final 段渲染为特殊气泡 (顶部有标记, 表示 LLM 显式终止)
|
|
198
|
+
const finalEl = buildBubble(seg.content, type);
|
|
199
|
+
finalEl.classList.add('bubble-final');
|
|
200
|
+
div.appendChild(finalEl);
|
|
201
|
+
renderedAny = true;
|
|
202
|
+
}
|
|
203
|
+
else if (seg.type === 'tool_call' && seg.tool) {
|
|
204
|
+
// tool_call segment 不渲染文字 — 走 step-timeline (步骤状态条)
|
|
205
|
+
// 这里只记录到 ctx 让外层在 addMessage 后挂到 timeline
|
|
206
|
+
if (ctx && ctx.toolCallCallback) {
|
|
207
|
+
ctx.toolCallCallback(seg.tool, div);
|
|
208
|
+
}
|
|
209
|
+
renderedAny = true; // 即使没文字, tool_call 也算"有意义"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!renderedAny) {
|
|
213
|
+
return; // 没有渲染任何东西, 不上屏
|
|
214
|
+
}
|
|
215
|
+
// 纯文本用于复制按钮 — 现在从 segments 拼, 不再依赖 cleanContent 变量
|
|
216
|
+
const rawContent = segments
|
|
217
|
+
.filter(s => s.type === 'text' || s.type === 'final')
|
|
218
|
+
.map(s => s.content || '')
|
|
219
|
+
.join('\n');
|
|
220
|
+
// 时间 — 2026-07-15 修 Bug 2: 历史消息恢复时用历史 timestamp, 不传则用"现在"
|
|
221
|
+
let timeLabel = '';
|
|
222
|
+
try {
|
|
223
|
+
if (timestamp !== undefined && timestamp !== null) {
|
|
224
|
+
const d = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
|
225
|
+
if (!isNaN(d.getTime())) {
|
|
226
|
+
timeLabel = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch { /* fallback below */ }
|
|
231
|
+
if (!timeLabel)
|
|
232
|
+
timeLabel = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
233
|
+
const time = document.createElement('div');
|
|
234
|
+
time.className = 'time';
|
|
235
|
+
time.textContent = timeLabel;
|
|
236
|
+
// AI 消息操作按钮
|
|
237
|
+
if (type === 'ai') {
|
|
238
|
+
div.appendChild(buildMessageActions(div, rawContent, ctx));
|
|
239
|
+
}
|
|
240
|
+
// P0.5 反向引用
|
|
241
|
+
if (type === 'ai' && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
|
|
242
|
+
const link = document.createElement('a');
|
|
243
|
+
link.className = 'used-judgments-link';
|
|
244
|
+
link.textContent = `📎 参考 ${usedJudgmentIds.length} 条原则`;
|
|
245
|
+
link.onclick = (e) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
if (typeof ctx.openJudgmentsModalWithFilter === 'function') {
|
|
248
|
+
ctx.openJudgmentsModalWithFilter(usedJudgmentIds);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
div.appendChild(link);
|
|
252
|
+
}
|
|
253
|
+
// 2026-06-15: 每条 AI 消息挂一个空 step-timeline 占位 (用户决策)
|
|
254
|
+
// 后续 step_start/step_done 事件会通过 handleStepEvent 推入
|
|
255
|
+
if (type === 'ai' && msgContainer) {
|
|
256
|
+
mountStepTimeline(div, currentChannelId);
|
|
257
|
+
// 2026-07-20 Bug 1: AI 消息创建后回放此前缓冲的 step 事件
|
|
258
|
+
flushStepEventBuffer(currentChannelId, ctx);
|
|
259
|
+
}
|
|
260
|
+
div.appendChild(time);
|
|
261
|
+
if (msgContainer) {
|
|
262
|
+
msgContainer.appendChild(div);
|
|
263
|
+
scheduleScrollToBottom(msgContainer);
|
|
140
264
|
}
|
|
141
|
-
} catch {
|
|
142
|
-
}
|
|
143
|
-
if (!timeLabel) timeLabel = (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
144
|
-
const time = document.createElement("div");
|
|
145
|
-
time.className = "time";
|
|
146
|
-
time.textContent = timeLabel;
|
|
147
|
-
if (type === "ai") {
|
|
148
|
-
div.appendChild(buildMessageActions(div, rawContent, ctx));
|
|
149
|
-
}
|
|
150
|
-
if (type === "ai" && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
|
|
151
|
-
const link = document.createElement("a");
|
|
152
|
-
link.className = "used-judgments-link";
|
|
153
|
-
link.textContent = `\u{1F4CE} \u53C2\u8003 ${usedJudgmentIds.length} \u6761\u539F\u5219`;
|
|
154
|
-
link.onclick = (e) => {
|
|
155
|
-
e.preventDefault();
|
|
156
|
-
if (typeof ctx.openJudgmentsModalWithFilter === "function") {
|
|
157
|
-
ctx.openJudgmentsModalWithFilter(usedJudgmentIds);
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
div.appendChild(link);
|
|
161
|
-
}
|
|
162
|
-
if (type === "ai" && msgContainer) {
|
|
163
|
-
mountStepTimeline(div, currentChannelId);
|
|
164
|
-
flushStepEventBuffer(currentChannelId, ctx);
|
|
165
|
-
}
|
|
166
|
-
div.appendChild(time);
|
|
167
|
-
if (msgContainer) {
|
|
168
|
-
msgContainer.appendChild(div);
|
|
169
|
-
scheduleScrollToBottom(msgContainer);
|
|
170
|
-
}
|
|
171
265
|
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// 私有: think 折叠块
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
172
269
|
function buildThinkContainer(thinkContent) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
270
|
+
const container = document.createElement('div');
|
|
271
|
+
container.className = 'think-container';
|
|
272
|
+
const toggle = document.createElement('div');
|
|
273
|
+
toggle.className = 'think-toggle';
|
|
274
|
+
toggle.innerHTML = '💭 思考过程 <span class="think-arrow">▸</span>';
|
|
275
|
+
toggle.onclick = function () {
|
|
276
|
+
const details = container.querySelector('.think-content');
|
|
277
|
+
const arrow = toggle.querySelector('.think-arrow');
|
|
278
|
+
if (!details || !arrow)
|
|
279
|
+
return;
|
|
280
|
+
if (details.style.display === 'none') {
|
|
281
|
+
details.style.display = 'block';
|
|
282
|
+
arrow.textContent = '▾';
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
details.style.display = 'none';
|
|
286
|
+
arrow.textContent = '▸';
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const content = document.createElement('div');
|
|
290
|
+
content.className = 'think-content';
|
|
291
|
+
content.style.display = 'none';
|
|
292
|
+
content.innerHTML = `<pre>${escapeHtml(thinkContent)}</pre>`;
|
|
293
|
+
container.appendChild(toggle);
|
|
294
|
+
container.appendChild(content);
|
|
295
|
+
return container;
|
|
197
296
|
}
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// 私有: environment_details 折叠块
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
198
300
|
function buildEnvContainer(envDetails) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
301
|
+
const container = document.createElement('div');
|
|
302
|
+
container.className = 'env-container';
|
|
303
|
+
const toggle = document.createElement('div');
|
|
304
|
+
toggle.className = 'env-toggle';
|
|
305
|
+
toggle.innerHTML = '⚙️ 环境信息 <span class="env-arrow">▸</span>';
|
|
306
|
+
toggle.onclick = function () {
|
|
307
|
+
const details = container.querySelector('.environment-details');
|
|
308
|
+
const arrow = toggle.querySelector('.env-arrow');
|
|
309
|
+
if (!details || !arrow)
|
|
310
|
+
return;
|
|
311
|
+
if (details.style.display === 'none') {
|
|
312
|
+
details.style.display = 'block';
|
|
313
|
+
arrow.textContent = '▾';
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
details.style.display = 'none';
|
|
317
|
+
arrow.textContent = '▸';
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const content = document.createElement('div');
|
|
321
|
+
content.className = 'environment-details';
|
|
322
|
+
content.style.display = 'none';
|
|
323
|
+
content.innerHTML = `<pre>${escapeHtml(envDetails)}</pre>`;
|
|
324
|
+
container.appendChild(toggle);
|
|
325
|
+
container.appendChild(content);
|
|
326
|
+
return container;
|
|
223
327
|
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// 私有: 气泡 (marked.parse 渲染)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
224
331
|
function buildBubble(text, type) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
332
|
+
const bubble = document.createElement('div');
|
|
333
|
+
bubble.className = `bubble bubble-${type}`;
|
|
334
|
+
// 安全降级: CDN 加载失败时 marked 是 escape 版本, 这里不需要二次 escape
|
|
335
|
+
// window.marked 在 ts 类型上不一定有, 用 any 兜底
|
|
336
|
+
const marked = window.marked;
|
|
337
|
+
bubble.innerHTML = marked ? marked.parse(text) : escapeHtml(text);
|
|
338
|
+
return bubble;
|
|
230
339
|
}
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// 私有: 消息操作按钮 (复制 / 重新回答 / 蒸馏为判断)
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
231
343
|
function buildMessageActions(div, rawContent, ctx) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
regenBtn.
|
|
260
|
-
regenBtn.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
344
|
+
const actions = document.createElement('div');
|
|
345
|
+
actions.className = 'message-actions';
|
|
346
|
+
// 复制
|
|
347
|
+
const copyBtn = document.createElement('button');
|
|
348
|
+
copyBtn.className = 'action-btn copy-btn';
|
|
349
|
+
copyBtn.innerHTML = copyIcon() + ' 复制';
|
|
350
|
+
copyBtn.title = '复制消息';
|
|
351
|
+
copyBtn.onclick = () => {
|
|
352
|
+
navigator.clipboard.writeText(rawContent).then(() => {
|
|
353
|
+
copyBtn.innerHTML = checkIcon() + ' 已复制';
|
|
354
|
+
setTimeout(() => { copyBtn.innerHTML = copyIcon() + ' 复制'; }, 2000);
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
actions.appendChild(copyBtn);
|
|
358
|
+
// 蒸馏为判断
|
|
359
|
+
const saveJudgmentBtn = document.createElement('button');
|
|
360
|
+
saveJudgmentBtn.className = 'action-btn save-as-judgment';
|
|
361
|
+
saveJudgmentBtn.title = 'AI 蒸馏为 30-80 字判断力 + 自动演化对齐';
|
|
362
|
+
saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800));
|
|
363
|
+
if (ctx.currentChannelId)
|
|
364
|
+
saveJudgmentBtn.setAttribute('data-channel-id', ctx.currentChannelId);
|
|
365
|
+
saveJudgmentBtn.innerHTML = shieldIcon() + ' 蒸馏为判断';
|
|
366
|
+
actions.appendChild(saveJudgmentBtn);
|
|
367
|
+
// 重新回答
|
|
368
|
+
const regenBtn = document.createElement('button');
|
|
369
|
+
regenBtn.className = 'action-btn regenerate-btn';
|
|
370
|
+
regenBtn.innerHTML = refreshIcon(false) + ' 重新回答';
|
|
371
|
+
regenBtn.title = '重新生成回复';
|
|
372
|
+
regenBtn.onclick = () => {
|
|
373
|
+
regenBtn.innerHTML = refreshIcon(true) + ' 生成中...';
|
|
374
|
+
regenBtn.disabled = true;
|
|
375
|
+
const messages = div.parentElement?.querySelectorAll('.message') || [];
|
|
376
|
+
let lastUserMsg = '';
|
|
377
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
378
|
+
const msg = messages[i];
|
|
379
|
+
if (msg.classList.contains('message-user')) {
|
|
380
|
+
const bubble = msg.querySelector('.bubble');
|
|
381
|
+
if (bubble) {
|
|
382
|
+
lastUserMsg = bubble.textContent || bubble.innerText || '';
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
270
386
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return actions;
|
|
290
|
-
}
|
|
291
|
-
function copyIcon() {
|
|
292
|
-
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
293
|
-
}
|
|
294
|
-
function checkIcon() {
|
|
295
|
-
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
|
296
|
-
}
|
|
297
|
-
function shieldIcon() {
|
|
298
|
-
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg>';
|
|
299
|
-
}
|
|
300
|
-
function refreshIcon(spin = false) {
|
|
301
|
-
const cls = spin ? ' class="spin"' : "";
|
|
302
|
-
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"${cls}><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"></path></svg>`;
|
|
387
|
+
fetch('/regenerate', {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({ channelId: ctx.currentChannelId, userMessage: lastUserMsg })
|
|
391
|
+
}).then(res => {
|
|
392
|
+
if (!res.ok)
|
|
393
|
+
throw new Error('regenerate failed');
|
|
394
|
+
}).catch(err => {
|
|
395
|
+
console.error('重新生成失败:', err);
|
|
396
|
+
regenBtn.innerHTML = refreshIcon(false) + ' 失败';
|
|
397
|
+
setTimeout(() => {
|
|
398
|
+
regenBtn.innerHTML = refreshIcon(false) + ' 重新回答';
|
|
399
|
+
regenBtn.disabled = false;
|
|
400
|
+
}, 2000);
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
actions.appendChild(regenBtn);
|
|
404
|
+
return actions;
|
|
303
405
|
}
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
406
|
+
function copyIcon() { return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; }
|
|
407
|
+
function checkIcon() { return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'; }
|
|
408
|
+
function shieldIcon() { return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg>'; }
|
|
409
|
+
function refreshIcon(spin = false) { const cls = spin ? ' class="spin"' : ''; return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"${cls}><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"></path></svg>`; }
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// 流式 token 处理 (textNode 增量, 不重排)
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
export function handleStreamTokenEvent(data, ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
|
|
414
|
+
const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
|
|
415
|
+
const messagesContainers = ctx.messagesContainers || new Map();
|
|
416
|
+
const currentChannelId = ctx.currentChannelId;
|
|
417
|
+
const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
|
|
418
|
+
if (!container)
|
|
419
|
+
return;
|
|
420
|
+
const delta = data.content || '';
|
|
421
|
+
if (!delta)
|
|
422
|
+
return;
|
|
423
|
+
if (!streamingMessageEl || !streamingMessageEl.isConnected) {
|
|
424
|
+
// 新建流式消息
|
|
425
|
+
streamingMessageEl = document.createElement('div');
|
|
426
|
+
streamingMessageEl.className = 'message message-ai message-streaming';
|
|
427
|
+
streamingTextNode = document.createTextNode('');
|
|
428
|
+
streamingMessageEl.appendChild(streamingTextNode);
|
|
429
|
+
streamingText = '';
|
|
430
|
+
// 2026-06-15: 流式期间也挂一个空 step-timeline 占位 — step_start/done/error 事件
|
|
431
|
+
// 走 handleStepEvent, getStepTimeline 找到这个流式元素内的 timeline 推入
|
|
432
|
+
mountStepTimeline(streamingMessageEl, currentChannelId);
|
|
433
|
+
container.appendChild(streamingMessageEl);
|
|
434
|
+
// 2026-07-21: 流式元素创建后立即回放缓冲的 step 事件, 让用户尽早看到 tool call 状态
|
|
435
|
+
// 必须先 appendChild 让元素 isConnected=true, 否则 flushStepEventBuffer → handleStepEvent
|
|
436
|
+
// 会因 streamingMessageEl.isConnected===false 而丢弃缓冲中的 step.
|
|
437
|
+
flushStepEventBuffer(currentChannelId, ctx);
|
|
438
|
+
// B-3: 首个 token 来时切状态到 streaming (蓝徽), 提示用户 panel 在工作
|
|
439
|
+
if (typeof ctx.setTimelineState === 'function') {
|
|
440
|
+
ctx.setTimelineState('streaming');
|
|
441
|
+
}
|
|
442
|
+
scheduleScrollToBottom(container);
|
|
443
|
+
}
|
|
444
|
+
// 2026-07-06: pivot loop 现在用 stream: false, 每次 emit type='token' + content=reply.substring(0, 100)
|
|
445
|
+
// 也就是说每个 token event 实际是"LLM 这一轮回复的前 100 字符", 不是真正的 token 增量.
|
|
446
|
+
// 多次 emit 会让 streamingText 累积成 "片段1 + 片段2 + ..." 而不是最终回复.
|
|
447
|
+
// 改成 "replace last segment" 语义: streamingText 始终是 LLM 最新一轮的回执的前缀.
|
|
448
|
+
// 这样 finalize 出来的 ai message bubble 就是当前最完整的那一轮 (通常是最新的, pivot loop 最后一次 reply),
|
|
449
|
+
// 用户看到的就是 LLM 真正的最终回答, 不是堆叠的中间产物.
|
|
450
|
+
if (data.streamType === 'token') {
|
|
451
|
+
// 把 streamingText 用 nodeValue 整体替换, 不累加
|
|
452
|
+
if (streamingTextNode)
|
|
453
|
+
streamingTextNode.nodeValue = delta;
|
|
454
|
+
streamingText = delta;
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// thinking 类的保持原本的 append 语义 (罕见, 留个口子)
|
|
458
|
+
if (streamingTextNode)
|
|
459
|
+
streamingTextNode.appendData(delta);
|
|
460
|
+
streamingText += delta;
|
|
323
461
|
}
|
|
324
462
|
scheduleScrollToBottom(container);
|
|
325
|
-
}
|
|
326
|
-
if (data.streamType === "token") {
|
|
327
|
-
if (streamingTextNode) streamingTextNode.nodeValue = delta;
|
|
328
|
-
streamingText = delta;
|
|
329
|
-
} else {
|
|
330
|
-
if (streamingTextNode) streamingTextNode.appendData(delta);
|
|
331
|
-
streamingText += delta;
|
|
332
|
-
}
|
|
333
|
-
scheduleScrollToBottom(container);
|
|
334
463
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// 流式消息 finalize (done 事件): 移除流式元素, 走 addMessage 走 marked.parse
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
export function finalizeTimelineAsMessage(ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
|
|
468
|
+
const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
|
|
469
|
+
const messagesContainers = ctx.messagesContainers || new Map();
|
|
470
|
+
const currentChannelId = ctx.currentChannelId;
|
|
471
|
+
const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
|
|
472
|
+
if (streamingText.trim().length > 0) {
|
|
473
|
+
// 2026-06-15: finalize 时把 streaming 内的 step-timeline 整体搬到新建的正式 AI message 内
|
|
474
|
+
// addMessage 会建一个新 timeline 占位, 先记下 streaming 的引用, addMessage 后再迁移
|
|
475
|
+
// 避免节点从 0 重渲 (10+ 步的任务, 重渲闪烁会很厉害)
|
|
476
|
+
const oldStreamingEl = streamingMessageEl;
|
|
477
|
+
if (oldStreamingEl && oldStreamingEl.parentNode) {
|
|
478
|
+
oldStreamingEl.parentNode.removeChild(oldStreamingEl);
|
|
479
|
+
}
|
|
480
|
+
addMessage(streamingText, 'ai', true, container, ctx.lastUsedJudgmentIds || [], ctx);
|
|
481
|
+
if (oldStreamingEl && container) {
|
|
482
|
+
// 找刚 addMessage 创建的最后一条 ai message
|
|
483
|
+
const newAiMsg = container.querySelector('.message-ai:last-of-type');
|
|
484
|
+
if (newAiMsg && newAiMsg !== oldStreamingEl) {
|
|
485
|
+
migrateStepTimeline(oldStreamingEl, newAiMsg);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
344
488
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
489
|
+
// 重置流式状态
|
|
490
|
+
streamingMessageEl = null;
|
|
491
|
+
streamingTextNode = null;
|
|
492
|
+
streamingText = '';
|
|
493
|
+
// B-3: finalize 时切 done 状态 (绿徽), hide 延迟由 client.js 的 hideTimelinePanel 统一管
|
|
494
|
+
if (typeof ctx.setTimelineState === 'function') {
|
|
495
|
+
ctx.setTimelineState('done');
|
|
351
496
|
}
|
|
352
|
-
}
|
|
353
|
-
streamingMessageEl = null;
|
|
354
|
-
streamingTextNode = null;
|
|
355
|
-
streamingText = "";
|
|
356
|
-
if (typeof ctx.setTimelineState === "function") {
|
|
357
|
-
ctx.setTimelineState("done");
|
|
358
|
-
}
|
|
359
497
|
}
|
|
360
|
-
function handleStepEvent(data, ctx = { messagesEl: null, messagesContainers:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
498
|
+
export function handleStepEvent(data, ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
|
|
499
|
+
const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
|
|
500
|
+
const messagesContainers = ctx.messagesContainers || new Map();
|
|
501
|
+
const currentChannelId = ctx.currentChannelId;
|
|
502
|
+
const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
|
|
503
|
+
if (!container)
|
|
504
|
+
return;
|
|
505
|
+
if (!data || !data.type)
|
|
506
|
+
return;
|
|
507
|
+
// 1. 优先用正在流式的元素 (流式期间能即时看到)
|
|
508
|
+
let target = streamingMessageEl && streamingMessageEl.isConnected
|
|
509
|
+
? streamingMessageEl
|
|
510
|
+
: null;
|
|
511
|
+
// 2. 无流式元素 → 缓冲等待 stream token 或 addMessage 后回放
|
|
512
|
+
// (2026-07-21: 不再回退到 welcome 等旧 AI 消息, 避免 step 贴错 message)
|
|
513
|
+
if (!target) {
|
|
514
|
+
if (currentChannelId) {
|
|
515
|
+
const buf = stepEventBuffer.get(currentChannelId) || [];
|
|
516
|
+
buf.push(data);
|
|
517
|
+
stepEventBuffer.set(currentChannelId, buf);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
373
520
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
521
|
+
if (!target)
|
|
522
|
+
return;
|
|
523
|
+
const timeline = getStepTimeline(target);
|
|
524
|
+
if (!timeline)
|
|
525
|
+
return;
|
|
526
|
+
pushStepToTimeline(timeline, data.type, {
|
|
527
|
+
tool: data.tool || 'unknown',
|
|
528
|
+
args: data.args,
|
|
529
|
+
success: data.success,
|
|
530
|
+
output: data.output,
|
|
531
|
+
error: data.error,
|
|
532
|
+
});
|
|
386
533
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
534
|
+
// 2026-07-20 Bug 1: 回放缓冲的 step 事件到刚创建的 AI 消息
|
|
535
|
+
export function flushStepEventBuffer(channelId, ctx) {
|
|
536
|
+
if (!channelId)
|
|
537
|
+
return;
|
|
538
|
+
const buf = stepEventBuffer.get(channelId);
|
|
539
|
+
if (!buf || buf.length === 0)
|
|
540
|
+
return;
|
|
541
|
+
stepEventBuffer.delete(channelId);
|
|
542
|
+
for (const evt of buf) {
|
|
543
|
+
handleStepEvent(evt, ctx);
|
|
544
|
+
}
|
|
395
545
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// 重置模块状态 (切频道时调用)
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
export function resetRendererState() {
|
|
550
|
+
streamingMessageEl = null;
|
|
551
|
+
streamingTextNode = null;
|
|
552
|
+
streamingText = '';
|
|
553
|
+
lastUserCommand = '';
|
|
554
|
+
lastAiContent = '';
|
|
555
|
+
if (scrollToBottomTimer) {
|
|
556
|
+
clearTimeout(scrollToBottomTimer);
|
|
557
|
+
scrollToBottomTimer = null;
|
|
558
|
+
}
|
|
406
559
|
}
|
|
407
|
-
const MessageRenderer = {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
560
|
+
export const MessageRenderer = {
|
|
561
|
+
addMessage,
|
|
562
|
+
handleStreamTokenEvent,
|
|
563
|
+
finalizeTimelineAsMessage,
|
|
564
|
+
handleStepEvent,
|
|
565
|
+
flushStepEventBuffer,
|
|
566
|
+
escapeHtml,
|
|
567
|
+
getMessagesContainerForCurrent,
|
|
568
|
+
resetRendererState,
|
|
416
569
|
};
|
|
417
|
-
if (typeof window !==
|
|
418
|
-
|
|
570
|
+
if (typeof window !== 'undefined') {
|
|
571
|
+
window.MR = MessageRenderer;
|
|
419
572
|
}
|
|
420
|
-
export {
|
|
421
|
-
MessageRenderer,
|
|
422
|
-
addMessage,
|
|
423
|
-
escapeHtml,
|
|
424
|
-
finalizeTimelineAsMessage,
|
|
425
|
-
flushStepEventBuffer,
|
|
426
|
-
getMessagesContainerForCurrent,
|
|
427
|
-
handleStepEvent,
|
|
428
|
-
handleStreamTokenEvent,
|
|
429
|
-
hasStreamingText,
|
|
430
|
-
injectRecoveredText,
|
|
431
|
-
replaceStreamingText,
|
|
432
|
-
resetRendererState
|
|
433
|
-
};
|