@bolloon/bolloon-agent 0.3.9 → 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.
@@ -1,433 +1,572 @@
1
- import {
2
- mountStepTimeline,
3
- pushStepToTimeline,
4
- migrateStepTimeline,
5
- getStepTimeline
6
- } from "./step-timeline.js";
7
- import { segmentChatReply } from "../../agents/chat-segmenter.js";
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
- const stepEventBuffer = /* @__PURE__ */ new Map();
14
- function hasStreamingText() {
15
- return streamingText.length > 0;
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
- function replaceStreamingText(fullContent) {
18
- if (!streamingTextNode || !streamingMessageEl) {
19
- return;
20
- }
21
- streamingTextNode.nodeValue = String(fullContent || "");
22
- streamingText = String(fullContent || "");
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
- function injectRecoveredText(partialText, ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
25
- if (streamingMessageEl && streamingTextNode) {
26
- streamingTextNode.nodeValue = String(partialText || "");
27
- streamingText = String(partialText || "");
28
- return;
29
- }
30
- handleStreamTokenEvent(
31
- {
32
- type: "token",
33
- streamType: "token",
34
- content: String(partialText || ""),
35
- delta: String(partialText || "")
36
- },
37
- ctx
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
- if (!container) return;
43
- if (scrollToBottomTimer) return;
44
- scrollToBottomTimer = setTimeout(() => {
45
- container.scrollTop = container.scrollHeight;
46
- scrollToBottomTimer = null;
47
- }, 60);
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
- function getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl) {
50
- if (currentChannelId && messagesContainers.get(currentChannelId)) {
51
- return messagesContainers.get(currentChannelId) || null;
52
- }
53
- return messagesEl;
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
- function escapeHtml(s) {
56
- return String(s ?? "").replace(/[&<>"']/g, (c) => ({
57
- "&": "&amp;",
58
- "<": "&lt;",
59
- ">": "&gt;",
60
- '"': "&quot;",
61
- "'": "&#39;"
62
- })[c]);
112
+ // ---------------------------------------------------------------------------
113
+ // HTML escape ( think / env 折叠块用)
114
+ // ---------------------------------------------------------------------------
115
+ export function escapeHtml(s) {
116
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
117
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
118
+ }[c]));
63
119
  }
64
- function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }, timestamp) {
65
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
66
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
67
- const currentChannelId = ctx.currentChannelId;
68
- const msgContainer = container || getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
69
- if (!save && msgContainer && msgContainer.children.length > 200) {
70
- const toRemove = msgContainer.children.length - 200;
71
- for (let i = 0; i < toRemove; i++) {
72
- const first = msgContainer.firstElementChild;
73
- if (first) msgContainer.removeChild(first);
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
- if (save) {
77
- const lastContent = type === "user" ? lastUserCommand : lastAiContent;
78
- if (lastContent && content === lastContent) {
79
- console.log(`[addMessage] \u8DF3\u8FC7\u91CD\u590D\u7684 ${type} \u6D88\u606F`);
80
- return;
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
- if (type === "user") lastUserCommand = content;
83
- else lastAiContent = content;
84
- }
85
- const div = document.createElement("div");
86
- div.className = `message message-${type}`;
87
- let cleanContent = content;
88
- if (type === "ai") {
89
- cleanContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/g, "");
90
- const finalGenIdx = cleanContent.indexOf("<final gen>");
91
- if (finalGenIdx >= 0) {
92
- cleanContent = cleanContent.substring(0, finalGenIdx).trim();
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
- const knownToolNames = ctx && ctx.knownToolNames || /* @__PURE__ */ new Set();
96
- const segments = segmentChatReply(cleanContent, { knownToolNames });
97
- if (segments.length === 0) {
98
- return;
99
- }
100
- let thinkContainer = null;
101
- let renderedAny = false;
102
- for (const seg of segments) {
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
- if (!renderedAny) {
130
- return;
131
- }
132
- const rawContent = segments.filter((s) => s.type === "text" || s.type === "final").map((s) => s.content || "").join("\n");
133
- let timeLabel = "";
134
- try {
135
- if (timestamp !== void 0 && timestamp !== null) {
136
- const d = timestamp instanceof Date ? timestamp : new Date(timestamp);
137
- if (!isNaN(d.getTime())) {
138
- timeLabel = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
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
- const container = document.createElement("div");
174
- container.className = "think-container";
175
- const toggle = document.createElement("div");
176
- toggle.className = "think-toggle";
177
- toggle.innerHTML = '\u{1F4AD} \u601D\u8003\u8FC7\u7A0B <span class="think-arrow">\u25B8</span>';
178
- toggle.onclick = function() {
179
- const details = container.querySelector(".think-content");
180
- const arrow = toggle.querySelector(".think-arrow");
181
- if (!details || !arrow) return;
182
- if (details.style.display === "none") {
183
- details.style.display = "block";
184
- arrow.textContent = "\u25BE";
185
- } else {
186
- details.style.display = "none";
187
- arrow.textContent = "\u25B8";
188
- }
189
- };
190
- const content = document.createElement("div");
191
- content.className = "think-content";
192
- content.style.display = "none";
193
- content.innerHTML = `<pre>${escapeHtml(thinkContent)}</pre>`;
194
- container.appendChild(toggle);
195
- container.appendChild(content);
196
- return container;
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
- const container = document.createElement("div");
200
- container.className = "env-container";
201
- const toggle = document.createElement("div");
202
- toggle.className = "env-toggle";
203
- toggle.innerHTML = '\u2699\uFE0F \u73AF\u5883\u4FE1\u606F <span class="env-arrow">\u25B8</span>';
204
- toggle.onclick = function() {
205
- const details = container.querySelector(".environment-details");
206
- const arrow = toggle.querySelector(".env-arrow");
207
- if (!details || !arrow) return;
208
- if (details.style.display === "none") {
209
- details.style.display = "block";
210
- arrow.textContent = "\u25BE";
211
- } else {
212
- details.style.display = "none";
213
- arrow.textContent = "\u25B8";
214
- }
215
- };
216
- const content = document.createElement("div");
217
- content.className = "environment-details";
218
- content.style.display = "none";
219
- content.innerHTML = `<pre>${escapeHtml(envDetails)}</pre>`;
220
- container.appendChild(toggle);
221
- container.appendChild(content);
222
- return container;
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
- const bubble = document.createElement("div");
226
- bubble.className = `bubble bubble-${type}`;
227
- const marked = window.marked;
228
- bubble.innerHTML = marked ? marked.parse(text) : escapeHtml(text);
229
- return bubble;
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
- const actions = document.createElement("div");
233
- actions.className = "message-actions";
234
- const copyBtn = document.createElement("button");
235
- copyBtn.className = "action-btn copy-btn";
236
- copyBtn.innerHTML = copyIcon() + " \u590D\u5236";
237
- copyBtn.title = "\u590D\u5236\u6D88\u606F";
238
- copyBtn.onclick = () => {
239
- navigator.clipboard.writeText(rawContent).then(() => {
240
- copyBtn.innerHTML = checkIcon() + " \u5DF2\u590D\u5236";
241
- setTimeout(() => {
242
- copyBtn.innerHTML = copyIcon() + " \u590D\u5236";
243
- }, 2e3);
244
- });
245
- };
246
- actions.appendChild(copyBtn);
247
- const saveJudgmentBtn = document.createElement("button");
248
- saveJudgmentBtn.className = "action-btn save-as-judgment";
249
- saveJudgmentBtn.title = "AI \u84B8\u998F\u4E3A 30-80 \u5B57\u5224\u65AD\u529B + \u81EA\u52A8\u6F14\u5316\u5BF9\u9F50";
250
- saveJudgmentBtn.setAttribute("data-decision", rawContent.substring(0, 800));
251
- if (ctx.currentChannelId) saveJudgmentBtn.setAttribute("data-channel-id", ctx.currentChannelId);
252
- saveJudgmentBtn.innerHTML = shieldIcon() + " \u84B8\u998F\u4E3A\u5224\u65AD";
253
- actions.appendChild(saveJudgmentBtn);
254
- const regenBtn = document.createElement("button");
255
- regenBtn.className = "action-btn regenerate-btn";
256
- regenBtn.innerHTML = refreshIcon(false) + " \u91CD\u65B0\u56DE\u7B54";
257
- regenBtn.title = "\u91CD\u65B0\u751F\u6210\u56DE\u590D";
258
- regenBtn.onclick = () => {
259
- regenBtn.innerHTML = refreshIcon(true) + " \u751F\u6210\u4E2D...";
260
- regenBtn.disabled = true;
261
- const messages = div.parentElement?.querySelectorAll(".message") || [];
262
- let lastUserMsg = "";
263
- for (let i = messages.length - 1; i >= 0; i--) {
264
- const msg = messages[i];
265
- if (msg.classList.contains("message-user")) {
266
- const bubble = msg.querySelector(".bubble");
267
- if (bubble) {
268
- lastUserMsg = bubble.textContent || bubble.innerText || "";
269
- break;
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
- fetch("/regenerate", {
274
- method: "POST",
275
- headers: { "Content-Type": "application/json" },
276
- body: JSON.stringify({ channelId: ctx.currentChannelId, userMessage: lastUserMsg })
277
- }).then((res) => {
278
- if (!res.ok) throw new Error("regenerate failed");
279
- }).catch((err) => {
280
- console.error("\u91CD\u65B0\u751F\u6210\u5931\u8D25:", err);
281
- regenBtn.innerHTML = refreshIcon(false) + " \u5931\u8D25";
282
- setTimeout(() => {
283
- regenBtn.innerHTML = refreshIcon(false) + " \u91CD\u65B0\u56DE\u7B54";
284
- regenBtn.disabled = false;
285
- }, 2e3);
286
- });
287
- };
288
- actions.appendChild(regenBtn);
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 handleStreamTokenEvent(data, ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
305
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
306
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
307
- const currentChannelId = ctx.currentChannelId;
308
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
309
- if (!container) return;
310
- const delta = data.content || "";
311
- if (!delta) return;
312
- if (!streamingMessageEl || !streamingMessageEl.isConnected) {
313
- streamingMessageEl = document.createElement("div");
314
- streamingMessageEl.className = "message message-ai message-streaming";
315
- streamingTextNode = document.createTextNode("");
316
- streamingMessageEl.appendChild(streamingTextNode);
317
- streamingText = "";
318
- mountStepTimeline(streamingMessageEl, currentChannelId);
319
- container.appendChild(streamingMessageEl);
320
- flushStepEventBuffer(currentChannelId, ctx);
321
- if (typeof ctx.setTimelineState === "function") {
322
- ctx.setTimelineState("streaming");
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
- function finalizeTimelineAsMessage(ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
336
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
337
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
338
- const currentChannelId = ctx.currentChannelId;
339
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
340
- if (streamingText.trim().length > 0) {
341
- const oldStreamingEl = streamingMessageEl;
342
- if (oldStreamingEl && oldStreamingEl.parentNode) {
343
- oldStreamingEl.parentNode.removeChild(oldStreamingEl);
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
- addMessage(streamingText, "ai", true, container, ctx.lastUsedJudgmentIds || [], ctx);
346
- if (oldStreamingEl && container) {
347
- const newAiMsg = container.querySelector(".message-ai:last-of-type");
348
- if (newAiMsg && newAiMsg !== oldStreamingEl) {
349
- migrateStepTimeline(oldStreamingEl, newAiMsg);
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: /* @__PURE__ */ new Map(), currentChannelId: null }) {
361
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
362
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
363
- const currentChannelId = ctx.currentChannelId;
364
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
365
- if (!container) return;
366
- if (!data || !data.type) return;
367
- let target = streamingMessageEl && streamingMessageEl.isConnected ? streamingMessageEl : null;
368
- if (!target) {
369
- if (currentChannelId) {
370
- const buf = stepEventBuffer.get(currentChannelId) || [];
371
- buf.push(data);
372
- stepEventBuffer.set(currentChannelId, buf);
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
- return;
375
- }
376
- if (!target) return;
377
- const timeline = getStepTimeline(target);
378
- if (!timeline) return;
379
- pushStepToTimeline(timeline, data.type, {
380
- tool: data.tool || "unknown",
381
- args: data.args,
382
- success: data.success,
383
- output: data.output,
384
- error: data.error
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
- function flushStepEventBuffer(channelId, ctx) {
388
- if (!channelId) return;
389
- const buf = stepEventBuffer.get(channelId);
390
- if (!buf || buf.length === 0) return;
391
- stepEventBuffer.delete(channelId);
392
- for (const evt of buf) {
393
- handleStepEvent(evt, ctx);
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
- function resetRendererState() {
397
- streamingMessageEl = null;
398
- streamingTextNode = null;
399
- streamingText = "";
400
- lastUserCommand = "";
401
- lastAiContent = "";
402
- if (scrollToBottomTimer) {
403
- clearTimeout(scrollToBottomTimer);
404
- scrollToBottomTimer = null;
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
- addMessage,
409
- handleStreamTokenEvent,
410
- finalizeTimelineAsMessage,
411
- handleStepEvent,
412
- flushStepEventBuffer,
413
- escapeHtml,
414
- getMessagesContainerForCurrent,
415
- resetRendererState
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 !== "undefined") {
418
- window.MR = MessageRenderer;
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
- };