@bolloon/bolloon-agent 0.2.0 → 0.2.2

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.
Files changed (42) hide show
  1. package/dist/agents/judgment-protocol.js +479 -0
  2. package/dist/agents/pi-sdk.js +269 -1
  3. package/dist/electron/config.js +9 -14
  4. package/dist/electron/dialogs.js +16 -53
  5. package/dist/electron/first-run.js +24 -65
  6. package/dist/electron/ipc.js +10 -14
  7. package/dist/electron/logger.js +7 -44
  8. package/dist/electron/main.js +38 -41
  9. package/dist/electron/menu.js +13 -18
  10. package/dist/electron/paths.js +12 -54
  11. package/dist/electron/server.js +18 -56
  12. package/dist/electron/tray.js +15 -53
  13. package/dist/electron/window.js +22 -61
  14. package/dist/security/tool-gate.js +4 -0
  15. package/dist/web/client.js +3767 -2873
  16. package/dist/web/components/p2p/P2PModal.js +188 -0
  17. package/dist/web/components/p2p/index.js +264 -226
  18. package/dist/web/components/p2p/p2p-modal.js +657 -0
  19. package/dist/web/components/p2p/p2p-tools.js +248 -0
  20. package/dist/web/ui/message-renderer.js +442 -326
  21. package/dist/web/ui/step-timeline.js +351 -255
  22. package/ios/App/App/AppDelegate.swift +49 -0
  23. package/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png +0 -0
  24. package/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  25. package/ios/App/App/Assets.xcassets/Contents.json +6 -0
  26. package/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json +23 -0
  27. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png +0 -0
  28. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png +0 -0
  29. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png +0 -0
  30. package/ios/App/App/Base.lproj/LaunchScreen.storyboard +32 -0
  31. package/ios/App/App/Base.lproj/Main.storyboard +19 -0
  32. package/ios/App/App/Info.plist +88 -0
  33. package/ios/App/App.xcodeproj/project.pbxproj +376 -0
  34. package/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  35. package/ios/App/CapApp-SPM/Package.swift +25 -0
  36. package/ios/App/CapApp-SPM/README.md +5 -0
  37. package/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift +1 -0
  38. package/ios/debug.xcconfig +1 -0
  39. package/package.json +14 -3
  40. package/scripts/build-app-bundle.cjs +170 -0
  41. package/.comm/README.md +0 -21
  42. package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +0 -7
@@ -1,356 +1,472 @@
1
- import {
2
- mountStepTimeline,
3
- pushStepToTimeline,
4
- migrateStepTimeline,
5
- getStepTimeline
6
- } from "./step-timeline.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
+ // ---------------------------------------------------------------------------
44
+ // 模块私有状态
45
+ // ---------------------------------------------------------------------------
7
46
  let streamingMessageEl = null;
8
47
  let streamingTextNode = null;
9
- let streamingText = "";
10
- let lastUserCommand = "";
11
- let lastAiContent = "";
12
- function hasStreamingText() {
13
- return streamingText.length > 0;
48
+ let streamingText = '';
49
+ let lastUserCommand = '';
50
+ let lastAiContent = '';
51
+ /**
52
+ * 2026-06-17: 流式状态查询 — 客户端用它在收到 server `ai` 事件时判断是否需要跳过 addMessage,
53
+ * 避免和后续 `done` → finalizeTimelineAsMessage 产生双气泡.
54
+ * 流式过程中 (streamingText > 0) server 推 `ai(content=fullResponse)` 时跳过,
55
+ * 真正渲染走 `done` 触发的 finalizeTimelineAsMessage.
56
+ */
57
+ export function hasStreamingText() {
58
+ return streamingText.length > 0;
14
59
  }
60
+ // 滚动限频 (60ms 16fps, 减 reflow)
15
61
  let scrollToBottomTimer = null;
16
62
  function scheduleScrollToBottom(container) {
17
- if (!container) return;
18
- if (scrollToBottomTimer) return;
19
- scrollToBottomTimer = setTimeout(() => {
20
- container.scrollTop = container.scrollHeight;
21
- scrollToBottomTimer = null;
22
- }, 60);
63
+ if (!container)
64
+ return;
65
+ if (scrollToBottomTimer)
66
+ return;
67
+ scrollToBottomTimer = setTimeout(() => {
68
+ container.scrollTop = container.scrollHeight;
69
+ scrollToBottomTimer = null;
70
+ }, 60);
23
71
  }
24
- function getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl) {
25
- if (currentChannelId && messagesContainers.get(currentChannelId)) {
26
- return messagesContainers.get(currentChannelId) || null;
27
- }
28
- return messagesEl;
72
+ // ---------------------------------------------------------------------------
73
+ // 容器选择 (主入口或 per-channel 容器)
74
+ // ---------------------------------------------------------------------------
75
+ export function getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl) {
76
+ if (currentChannelId && messagesContainers.get(currentChannelId)) {
77
+ return messagesContainers.get(currentChannelId) || null;
78
+ }
79
+ return messagesEl;
29
80
  }
30
- function escapeHtml(s) {
31
- return String(s ?? "").replace(/[&<>"']/g, (c) => ({
32
- "&": "&amp;",
33
- "<": "&lt;",
34
- ">": "&gt;",
35
- '"': "&quot;",
36
- "'": "&#39;"
37
- })[c]);
81
+ // ---------------------------------------------------------------------------
82
+ // HTML escape ( think / env 折叠块用)
83
+ // ---------------------------------------------------------------------------
84
+ export function escapeHtml(s) {
85
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
86
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
87
+ }[c]));
38
88
  }
39
- function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
40
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
41
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
42
- const currentChannelId = ctx.currentChannelId;
43
- const msgContainer = container || getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
44
- if (!save && msgContainer && msgContainer.children.length > 200) {
45
- const toRemove = msgContainer.children.length - 200;
46
- for (let i = 0; i < toRemove; i++) {
47
- const first = msgContainer.firstElementChild;
48
- if (first) msgContainer.removeChild(first);
89
+ // ---------------------------------------------------------------------------
90
+ // 主入口: 渲染一条消息气泡 (user / ai / system)
91
+ // ---------------------------------------------------------------------------
92
+ export function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
93
+ const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
94
+ const messagesContainers = ctx.messagesContainers || new Map();
95
+ const currentChannelId = ctx.currentChannelId;
96
+ const msgContainer = container || getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
97
+ // 内存保护: 单个 channel 容器超过 200 条, 旧会话加载时淘汰最旧
98
+ if (!save && msgContainer && msgContainer.children.length > 200) {
99
+ const toRemove = msgContainer.children.length - 200;
100
+ for (let i = 0; i < toRemove; i++) {
101
+ const first = msgContainer.firstElementChild;
102
+ if (first)
103
+ msgContainer.removeChild(first);
104
+ }
49
105
  }
50
- }
51
- if (save) {
52
- const lastContent = type === "user" ? lastUserCommand : lastAiContent;
53
- if (lastContent && content === lastContent) {
54
- console.log(`[addMessage] \u8DF3\u8FC7\u91CD\u590D\u7684 ${type} \u6D88\u606F`);
55
- return;
106
+ // 去重 (save=true 时)
107
+ if (save) {
108
+ const lastContent = type === 'user' ? lastUserCommand : lastAiContent;
109
+ if (lastContent && content === lastContent) {
110
+ console.log(`[addMessage] 跳过重复的 ${type} 消息`);
111
+ return;
112
+ }
113
+ if (type === 'user')
114
+ lastUserCommand = content;
115
+ else
116
+ lastAiContent = content;
117
+ }
118
+ const div = document.createElement('div');
119
+ div.className = `message message-${type}`;
120
+ // 清理: 移除 tool_call 标记
121
+ let cleanContent = content
122
+ .replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, '')
123
+ .replace(/TOOL_CALL[\s\S]*?\/TOOL_CALL/g, '')
124
+ .replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
125
+ .replace(/\{\s*"tool":[\s\S]*?\}/g, '')
126
+ .replace(/\{\s*tool\s*=>\s*["'][^"']+["']\s*(?:,\s*args\s*=>\s*\{[\s\S]*?\})?\s*\}/g, '')
127
+ .replace(/\[Function[^\]]*\]\s*/g, '')
128
+ .trim();
129
+ // think 折叠
130
+ const thinkMatch = cleanContent.match(/<think>([\s\S]*?)<\/think>/);
131
+ let mainContent = cleanContent;
132
+ let thinkContainer = null;
133
+ if (thinkMatch) {
134
+ const thinkContent = thinkMatch[1].trim();
135
+ mainContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/, '').trim();
136
+ thinkContainer = buildThinkContainer(thinkContent);
137
+ }
138
+ // environment_details 折叠 + 身份头
139
+ const envMatch = mainContent.match(/^(.+?)\n<environment_details>([\s\S]*?)<\/environment_details>\n([\s\S]*)$/);
140
+ if (envMatch) {
141
+ const identity = envMatch[1].trim();
142
+ const envDetails = envMatch[2].trim();
143
+ const messageBody = envMatch[3].trim();
144
+ const header = document.createElement('div');
145
+ header.className = 'message-header';
146
+ header.textContent = identity;
147
+ div.appendChild(header);
148
+ div.appendChild(buildEnvContainer(envDetails));
149
+ if (thinkContainer)
150
+ div.appendChild(thinkContainer);
151
+ if (messageBody)
152
+ div.appendChild(buildBubble(messageBody, type));
153
+ }
154
+ else if (cleanContent) {
155
+ if (thinkContainer)
156
+ div.appendChild(thinkContainer);
157
+ div.appendChild(buildBubble(cleanContent, type));
158
+ }
159
+ else {
160
+ return; // 空内容, 不上屏
161
+ }
162
+ // 纯文本用于复制按钮
163
+ const rawContent = cleanContent
164
+ .replace(/<[^>]+>/g, '')
165
+ .replace(/&lt;/g, '<')
166
+ .replace(/&gt;/g, '>')
167
+ .replace(/&amp;/g, '&')
168
+ .replace(/&nbsp;/g, ' ');
169
+ // 时间
170
+ const time = document.createElement('div');
171
+ time.className = 'time';
172
+ time.textContent = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
173
+ // AI 消息操作按钮
174
+ if (type === 'ai') {
175
+ div.appendChild(buildMessageActions(div, rawContent, ctx));
176
+ }
177
+ // P0.5 反向引用
178
+ if (type === 'ai' && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
179
+ const link = document.createElement('a');
180
+ link.className = 'used-judgments-link';
181
+ link.textContent = `📎 参考 ${usedJudgmentIds.length} 条原则`;
182
+ link.onclick = (e) => {
183
+ e.preventDefault();
184
+ if (typeof ctx.openJudgmentsModalWithFilter === 'function') {
185
+ ctx.openJudgmentsModalWithFilter(usedJudgmentIds);
186
+ }
187
+ };
188
+ div.appendChild(link);
189
+ }
190
+ // 2026-06-15: 每条 AI 消息挂一个空 step-timeline 占位 (用户决策)
191
+ // 后续 step_start/step_done 事件会通过 handleStepEvent 推入
192
+ if (type === 'ai' && msgContainer) {
193
+ mountStepTimeline(div, currentChannelId);
194
+ }
195
+ div.appendChild(time);
196
+ if (msgContainer) {
197
+ msgContainer.appendChild(div);
198
+ scheduleScrollToBottom(msgContainer);
56
199
  }
57
- if (type === "user") lastUserCommand = content;
58
- else lastAiContent = content;
59
- }
60
- const div = document.createElement("div");
61
- div.className = `message message-${type}`;
62
- let cleanContent = content.replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, "").replace(/TOOL_CALL[\s\S]*?\/TOOL_CALL/g, "").replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, "").replace(/\{\s*"tool":[\s\S]*?\}/g, "").replace(/\{\s*tool\s*=>\s*["'][^"']+["']\s*(?:,\s*args\s*=>\s*\{[\s\S]*?\})?\s*\}/g, "").replace(/\[Function[^\]]*\]\s*/g, "").trim();
63
- const thinkMatch = cleanContent.match(/<think>([\s\S]*?)<\/think>/);
64
- let mainContent = cleanContent;
65
- let thinkContainer = null;
66
- if (thinkMatch) {
67
- const thinkContent = thinkMatch[1].trim();
68
- mainContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/, "").trim();
69
- thinkContainer = buildThinkContainer(thinkContent);
70
- }
71
- const envMatch = mainContent.match(/^(.+?)\n<environment_details>([\s\S]*?)<\/environment_details>\n([\s\S]*)$/);
72
- if (envMatch) {
73
- const identity = envMatch[1].trim();
74
- const envDetails = envMatch[2].trim();
75
- const messageBody = envMatch[3].trim();
76
- const header = document.createElement("div");
77
- header.className = "message-header";
78
- header.textContent = identity;
79
- div.appendChild(header);
80
- div.appendChild(buildEnvContainer(envDetails));
81
- if (thinkContainer) div.appendChild(thinkContainer);
82
- if (messageBody) div.appendChild(buildBubble(messageBody, type));
83
- } else if (cleanContent) {
84
- if (thinkContainer) div.appendChild(thinkContainer);
85
- div.appendChild(buildBubble(cleanContent, type));
86
- } else {
87
- return;
88
- }
89
- const rawContent = cleanContent.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
90
- const time = document.createElement("div");
91
- time.className = "time";
92
- time.textContent = (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
93
- if (type === "ai") {
94
- div.appendChild(buildMessageActions(div, rawContent, ctx));
95
- }
96
- if (type === "ai" && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
97
- const link = document.createElement("a");
98
- link.className = "used-judgments-link";
99
- link.textContent = `\u{1F4CE} \u53C2\u8003 ${usedJudgmentIds.length} \u6761\u539F\u5219`;
100
- link.onclick = (e) => {
101
- e.preventDefault();
102
- if (typeof ctx.openJudgmentsModalWithFilter === "function") {
103
- ctx.openJudgmentsModalWithFilter(usedJudgmentIds);
104
- }
105
- };
106
- div.appendChild(link);
107
- }
108
- if (type === "ai" && msgContainer) {
109
- mountStepTimeline(div, currentChannelId);
110
- }
111
- div.appendChild(time);
112
- if (msgContainer) {
113
- msgContainer.appendChild(div);
114
- scheduleScrollToBottom(msgContainer);
115
- }
116
200
  }
201
+ // ---------------------------------------------------------------------------
202
+ // 私有: think 折叠块
203
+ // ---------------------------------------------------------------------------
117
204
  function buildThinkContainer(thinkContent) {
118
- const container = document.createElement("div");
119
- container.className = "think-container";
120
- const toggle = document.createElement("div");
121
- toggle.className = "think-toggle";
122
- toggle.innerHTML = '\u{1F4AD} \u601D\u8003\u8FC7\u7A0B <span class="think-arrow">\u25B8</span>';
123
- toggle.onclick = function() {
124
- const details = container.querySelector(".think-content");
125
- const arrow = toggle.querySelector(".think-arrow");
126
- if (!details || !arrow) return;
127
- if (details.style.display === "none") {
128
- details.style.display = "block";
129
- arrow.textContent = "\u25BE";
130
- } else {
131
- details.style.display = "none";
132
- arrow.textContent = "\u25B8";
133
- }
134
- };
135
- const content = document.createElement("div");
136
- content.className = "think-content";
137
- content.style.display = "none";
138
- content.innerHTML = `<pre>${escapeHtml(thinkContent)}</pre>`;
139
- container.appendChild(toggle);
140
- container.appendChild(content);
141
- return container;
205
+ const container = document.createElement('div');
206
+ container.className = 'think-container';
207
+ const toggle = document.createElement('div');
208
+ toggle.className = 'think-toggle';
209
+ toggle.innerHTML = '💭 思考过程 <span class="think-arrow">▸</span>';
210
+ toggle.onclick = function () {
211
+ const details = container.querySelector('.think-content');
212
+ const arrow = toggle.querySelector('.think-arrow');
213
+ if (!details || !arrow)
214
+ return;
215
+ if (details.style.display === 'none') {
216
+ details.style.display = 'block';
217
+ arrow.textContent = '▾';
218
+ }
219
+ else {
220
+ details.style.display = 'none';
221
+ arrow.textContent = '▸';
222
+ }
223
+ };
224
+ const content = document.createElement('div');
225
+ content.className = 'think-content';
226
+ content.style.display = 'none';
227
+ content.innerHTML = `<pre>${escapeHtml(thinkContent)}</pre>`;
228
+ container.appendChild(toggle);
229
+ container.appendChild(content);
230
+ return container;
142
231
  }
232
+ // ---------------------------------------------------------------------------
233
+ // 私有: environment_details 折叠块
234
+ // ---------------------------------------------------------------------------
143
235
  function buildEnvContainer(envDetails) {
144
- const container = document.createElement("div");
145
- container.className = "env-container";
146
- const toggle = document.createElement("div");
147
- toggle.className = "env-toggle";
148
- toggle.innerHTML = '\u2699\uFE0F \u73AF\u5883\u4FE1\u606F <span class="env-arrow">\u25B8</span>';
149
- toggle.onclick = function() {
150
- const details = container.querySelector(".environment-details");
151
- const arrow = toggle.querySelector(".env-arrow");
152
- if (!details || !arrow) return;
153
- if (details.style.display === "none") {
154
- details.style.display = "block";
155
- arrow.textContent = "\u25BE";
156
- } else {
157
- details.style.display = "none";
158
- arrow.textContent = "\u25B8";
159
- }
160
- };
161
- const content = document.createElement("div");
162
- content.className = "environment-details";
163
- content.style.display = "none";
164
- content.innerHTML = `<pre>${escapeHtml(envDetails)}</pre>`;
165
- container.appendChild(toggle);
166
- container.appendChild(content);
167
- return container;
236
+ const container = document.createElement('div');
237
+ container.className = 'env-container';
238
+ const toggle = document.createElement('div');
239
+ toggle.className = 'env-toggle';
240
+ toggle.innerHTML = '⚙️ 环境信息 <span class="env-arrow">▸</span>';
241
+ toggle.onclick = function () {
242
+ const details = container.querySelector('.environment-details');
243
+ const arrow = toggle.querySelector('.env-arrow');
244
+ if (!details || !arrow)
245
+ return;
246
+ if (details.style.display === 'none') {
247
+ details.style.display = 'block';
248
+ arrow.textContent = '▾';
249
+ }
250
+ else {
251
+ details.style.display = 'none';
252
+ arrow.textContent = '▸';
253
+ }
254
+ };
255
+ const content = document.createElement('div');
256
+ content.className = 'environment-details';
257
+ content.style.display = 'none';
258
+ content.innerHTML = `<pre>${escapeHtml(envDetails)}</pre>`;
259
+ container.appendChild(toggle);
260
+ container.appendChild(content);
261
+ return container;
168
262
  }
263
+ // ---------------------------------------------------------------------------
264
+ // 私有: 气泡 (marked.parse 渲染)
265
+ // ---------------------------------------------------------------------------
169
266
  function buildBubble(text, type) {
170
- const bubble = document.createElement("div");
171
- bubble.className = `bubble bubble-${type}`;
172
- const marked = window.marked;
173
- bubble.innerHTML = marked ? marked.parse(text) : escapeHtml(text);
174
- return bubble;
267
+ const bubble = document.createElement('div');
268
+ bubble.className = `bubble bubble-${type}`;
269
+ // 安全降级: CDN 加载失败时 marked escape 版本, 这里不需要二次 escape
270
+ // window.marked ts 类型上不一定有, any 兜底
271
+ const marked = window.marked;
272
+ bubble.innerHTML = marked ? marked.parse(text) : escapeHtml(text);
273
+ return bubble;
175
274
  }
275
+ // ---------------------------------------------------------------------------
276
+ // 私有: 消息操作按钮 (复制 / 重新回答 / 蒸馏为判断)
277
+ // ---------------------------------------------------------------------------
176
278
  function buildMessageActions(div, rawContent, ctx) {
177
- const actions = document.createElement("div");
178
- actions.className = "message-actions";
179
- const copyBtn = document.createElement("button");
180
- copyBtn.className = "action-btn copy-btn";
181
- copyBtn.innerHTML = copyIcon() + " \u590D\u5236";
182
- copyBtn.title = "\u590D\u5236\u6D88\u606F";
183
- copyBtn.onclick = () => {
184
- navigator.clipboard.writeText(rawContent).then(() => {
185
- copyBtn.innerHTML = checkIcon() + " \u5DF2\u590D\u5236";
186
- setTimeout(() => {
187
- copyBtn.innerHTML = copyIcon() + " \u590D\u5236";
188
- }, 2e3);
189
- });
190
- };
191
- actions.appendChild(copyBtn);
192
- const saveJudgmentBtn = document.createElement("button");
193
- saveJudgmentBtn.className = "action-btn save-as-judgment";
194
- saveJudgmentBtn.title = "AI \u84B8\u998F\u4E3A 30-80 \u5B57\u5224\u65AD\u529B + \u81EA\u52A8\u6F14\u5316\u5BF9\u9F50";
195
- saveJudgmentBtn.setAttribute("data-decision", rawContent.substring(0, 800));
196
- if (ctx.currentChannelId) saveJudgmentBtn.setAttribute("data-channel-id", ctx.currentChannelId);
197
- saveJudgmentBtn.innerHTML = shieldIcon() + " \u84B8\u998F\u4E3A\u5224\u65AD";
198
- actions.appendChild(saveJudgmentBtn);
199
- const regenBtn = document.createElement("button");
200
- regenBtn.className = "action-btn regenerate-btn";
201
- regenBtn.innerHTML = refreshIcon(false) + " \u91CD\u65B0\u56DE\u7B54";
202
- regenBtn.title = "\u91CD\u65B0\u751F\u6210\u56DE\u590D";
203
- regenBtn.onclick = () => {
204
- regenBtn.innerHTML = refreshIcon(true) + " \u751F\u6210\u4E2D...";
205
- regenBtn.disabled = true;
206
- const messages = div.parentElement?.querySelectorAll(".message") || [];
207
- let lastUserMsg = "";
208
- for (let i = messages.length - 1; i >= 0; i--) {
209
- const msg = messages[i];
210
- if (msg.classList.contains("message-user")) {
211
- const bubble = msg.querySelector(".bubble");
212
- if (bubble) {
213
- lastUserMsg = bubble.textContent || bubble.innerText || "";
214
- break;
279
+ const actions = document.createElement('div');
280
+ actions.className = 'message-actions';
281
+ // 复制
282
+ const copyBtn = document.createElement('button');
283
+ copyBtn.className = 'action-btn copy-btn';
284
+ copyBtn.innerHTML = copyIcon() + ' 复制';
285
+ copyBtn.title = '复制消息';
286
+ copyBtn.onclick = () => {
287
+ navigator.clipboard.writeText(rawContent).then(() => {
288
+ copyBtn.innerHTML = checkIcon() + ' 已复制';
289
+ setTimeout(() => { copyBtn.innerHTML = copyIcon() + ' 复制'; }, 2000);
290
+ });
291
+ };
292
+ actions.appendChild(copyBtn);
293
+ // 蒸馏为判断
294
+ const saveJudgmentBtn = document.createElement('button');
295
+ saveJudgmentBtn.className = 'action-btn save-as-judgment';
296
+ saveJudgmentBtn.title = 'AI 蒸馏为 30-80 字判断力 + 自动演化对齐';
297
+ saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800));
298
+ if (ctx.currentChannelId)
299
+ saveJudgmentBtn.setAttribute('data-channel-id', ctx.currentChannelId);
300
+ saveJudgmentBtn.innerHTML = shieldIcon() + ' 蒸馏为判断';
301
+ actions.appendChild(saveJudgmentBtn);
302
+ // 重新回答
303
+ const regenBtn = document.createElement('button');
304
+ regenBtn.className = 'action-btn regenerate-btn';
305
+ regenBtn.innerHTML = refreshIcon(false) + ' 重新回答';
306
+ regenBtn.title = '重新生成回复';
307
+ regenBtn.onclick = () => {
308
+ regenBtn.innerHTML = refreshIcon(true) + ' 生成中...';
309
+ regenBtn.disabled = true;
310
+ const messages = div.parentElement?.querySelectorAll('.message') || [];
311
+ let lastUserMsg = '';
312
+ for (let i = messages.length - 1; i >= 0; i--) {
313
+ const msg = messages[i];
314
+ if (msg.classList.contains('message-user')) {
315
+ const bubble = msg.querySelector('.bubble');
316
+ if (bubble) {
317
+ lastUserMsg = bubble.textContent || bubble.innerText || '';
318
+ break;
319
+ }
320
+ }
215
321
  }
216
- }
217
- }
218
- fetch("/regenerate", {
219
- method: "POST",
220
- headers: { "Content-Type": "application/json" },
221
- body: JSON.stringify({ channelId: ctx.currentChannelId, userMessage: lastUserMsg })
222
- }).then((res) => {
223
- if (!res.ok) throw new Error("regenerate failed");
224
- }).catch((err) => {
225
- console.error("\u91CD\u65B0\u751F\u6210\u5931\u8D25:", err);
226
- regenBtn.innerHTML = refreshIcon(false) + " \u5931\u8D25";
227
- setTimeout(() => {
228
- regenBtn.innerHTML = refreshIcon(false) + " \u91CD\u65B0\u56DE\u7B54";
229
- regenBtn.disabled = false;
230
- }, 2e3);
231
- });
232
- };
233
- actions.appendChild(regenBtn);
234
- return actions;
235
- }
236
- function copyIcon() {
237
- 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>';
238
- }
239
- function checkIcon() {
240
- 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>';
241
- }
242
- function shieldIcon() {
243
- 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>';
244
- }
245
- function refreshIcon(spin = false) {
246
- const cls = spin ? ' class="spin"' : "";
247
- 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>`;
322
+ fetch('/regenerate', {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json' },
325
+ body: JSON.stringify({ channelId: ctx.currentChannelId, userMessage: lastUserMsg })
326
+ }).then(res => {
327
+ if (!res.ok)
328
+ throw new Error('regenerate failed');
329
+ }).catch(err => {
330
+ console.error('重新生成失败:', err);
331
+ regenBtn.innerHTML = refreshIcon(false) + ' 失败';
332
+ setTimeout(() => {
333
+ regenBtn.innerHTML = refreshIcon(false) + ' 重新回答';
334
+ regenBtn.disabled = false;
335
+ }, 2000);
336
+ });
337
+ };
338
+ actions.appendChild(regenBtn);
339
+ return actions;
248
340
  }
249
- function handleStreamTokenEvent(data, ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
250
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
251
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
252
- const currentChannelId = ctx.currentChannelId;
253
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
254
- if (!container) return;
255
- const delta = data.content || "";
256
- if (!delta) return;
257
- if (!streamingMessageEl || !streamingMessageEl.isConnected) {
258
- streamingMessageEl = document.createElement("div");
259
- streamingMessageEl.className = "message message-ai message-streaming";
260
- streamingTextNode = document.createTextNode("");
261
- streamingMessageEl.appendChild(streamingTextNode);
262
- streamingText = "";
263
- mountStepTimeline(streamingMessageEl, currentChannelId);
264
- container.appendChild(streamingMessageEl);
265
- if (typeof ctx.setTimelineState === "function") {
266
- ctx.setTimelineState("streaming");
341
+ 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>'; }
342
+ 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>'; }
343
+ 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>'; }
344
+ 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>`; }
345
+ // ---------------------------------------------------------------------------
346
+ // 流式 token 处理 (textNode 增量, 不重排)
347
+ // ---------------------------------------------------------------------------
348
+ export function handleStreamTokenEvent(data, ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
349
+ const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
350
+ const messagesContainers = ctx.messagesContainers || new Map();
351
+ const currentChannelId = ctx.currentChannelId;
352
+ const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
353
+ if (!container)
354
+ return;
355
+ const delta = data.content || '';
356
+ if (!delta)
357
+ return;
358
+ if (!streamingMessageEl || !streamingMessageEl.isConnected) {
359
+ // 新建流式消息
360
+ streamingMessageEl = document.createElement('div');
361
+ streamingMessageEl.className = 'message message-ai message-streaming';
362
+ streamingTextNode = document.createTextNode('');
363
+ streamingMessageEl.appendChild(streamingTextNode);
364
+ streamingText = '';
365
+ // 2026-06-15: 流式期间也挂一个空 step-timeline 占位 — step_start/done/error 事件
366
+ // 走 handleStepEvent, getStepTimeline 找到这个流式元素内的 timeline 推入
367
+ mountStepTimeline(streamingMessageEl, currentChannelId);
368
+ container.appendChild(streamingMessageEl);
369
+ // B-3: 首个 token 来时切状态到 streaming (蓝徽), 提示用户 panel 在工作
370
+ if (typeof ctx.setTimelineState === 'function') {
371
+ ctx.setTimelineState('streaming');
372
+ }
373
+ scheduleScrollToBottom(container);
267
374
  }
375
+ if (streamingTextNode)
376
+ streamingTextNode.appendData(delta);
377
+ streamingText += delta;
268
378
  scheduleScrollToBottom(container);
269
- }
270
- if (streamingTextNode) streamingTextNode.appendData(delta);
271
- streamingText += delta;
272
- scheduleScrollToBottom(container);
273
379
  }
274
- function finalizeTimelineAsMessage(ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
275
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
276
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
277
- const currentChannelId = ctx.currentChannelId;
278
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
279
- if (streamingText.trim().length > 0) {
280
- const oldStreamingEl = streamingMessageEl;
281
- if (oldStreamingEl && oldStreamingEl.parentNode) {
282
- oldStreamingEl.parentNode.removeChild(oldStreamingEl);
380
+ // ---------------------------------------------------------------------------
381
+ // 流式消息 finalize (done 事件): 移除流式元素, addMessage marked.parse
382
+ // ---------------------------------------------------------------------------
383
+ export function finalizeTimelineAsMessage(ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
384
+ const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
385
+ const messagesContainers = ctx.messagesContainers || new Map();
386
+ const currentChannelId = ctx.currentChannelId;
387
+ const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
388
+ if (streamingText.trim().length > 0) {
389
+ // 2026-06-15: finalize 时把 streaming 内的 step-timeline 整体搬到新建的正式 AI message 内
390
+ // addMessage 会建一个新 timeline 占位, 先记下 streaming 的引用, addMessage 后再迁移
391
+ // 避免节点从 0 重渲 (10+ 步的任务, 重渲闪烁会很厉害)
392
+ const oldStreamingEl = streamingMessageEl;
393
+ if (oldStreamingEl && oldStreamingEl.parentNode) {
394
+ oldStreamingEl.parentNode.removeChild(oldStreamingEl);
395
+ }
396
+ addMessage(streamingText, 'ai', true, container, ctx.lastUsedJudgmentIds || [], ctx);
397
+ if (oldStreamingEl && container) {
398
+ // 找刚 addMessage 创建的最后一条 ai message
399
+ const newAiMsg = container.querySelector('.message-ai:last-of-type');
400
+ if (newAiMsg && newAiMsg !== oldStreamingEl) {
401
+ migrateStepTimeline(oldStreamingEl, newAiMsg);
402
+ }
403
+ }
283
404
  }
284
- addMessage(streamingText, "ai", true, container, ctx.lastUsedJudgmentIds || [], ctx);
285
- if (oldStreamingEl && container) {
286
- const newAiMsg = container.querySelector(".message-ai:last-of-type");
287
- if (newAiMsg && newAiMsg !== oldStreamingEl) {
288
- migrateStepTimeline(oldStreamingEl, newAiMsg);
289
- }
405
+ // 重置流式状态
406
+ streamingMessageEl = null;
407
+ streamingTextNode = null;
408
+ streamingText = '';
409
+ // B-3: finalize 时切 done 状态 (绿徽), hide 延迟由 client.js 的 hideTimelinePanel 统一管
410
+ if (typeof ctx.setTimelineState === 'function') {
411
+ ctx.setTimelineState('done');
290
412
  }
291
- }
292
- streamingMessageEl = null;
293
- streamingTextNode = null;
294
- streamingText = "";
295
- if (typeof ctx.setTimelineState === "function") {
296
- ctx.setTimelineState("done");
297
- }
298
413
  }
299
- function handleStepEvent(data, ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
300
- const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
301
- const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
302
- const currentChannelId = ctx.currentChannelId;
303
- const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
304
- if (!container) return;
305
- if (!data || !data.type) return;
306
- let target = streamingMessageEl && streamingMessageEl.isConnected ? streamingMessageEl : null;
307
- if (!target) {
308
- const aiMsgs = container.querySelectorAll(".message-ai");
309
- if (aiMsgs.length === 0) return;
310
- target = aiMsgs[aiMsgs.length - 1];
311
- }
312
- if (!target) return;
313
- const timeline = getStepTimeline(target);
314
- if (!timeline) return;
315
- pushStepToTimeline(timeline, data.type, {
316
- tool: data.tool || "unknown",
317
- args: data.args,
318
- success: data.success,
319
- output: data.output,
320
- error: data.error
321
- });
414
+ export function handleStepEvent(data, ctx = { messagesEl: null, messagesContainers: new Map(), currentChannelId: null }) {
415
+ const messagesEl = ctx.messagesEl || (typeof document !== 'undefined' ? document.getElementById('messages') : null);
416
+ const messagesContainers = ctx.messagesContainers || new Map();
417
+ const currentChannelId = ctx.currentChannelId;
418
+ const container = getMessagesContainerForCurrent(currentChannelId, messagesContainers, messagesEl);
419
+ if (!container)
420
+ return;
421
+ if (!data || !data.type)
422
+ return;
423
+ // 1. 优先用正在流式的元素 (流式期间能即时看到)
424
+ let target = streamingMessageEl && streamingMessageEl.isConnected
425
+ ? streamingMessageEl
426
+ : null;
427
+ // 2. 否则用最后一条 AI message
428
+ if (!target) {
429
+ const aiMsgs = container.querySelectorAll('.message-ai');
430
+ if (aiMsgs.length === 0)
431
+ return;
432
+ target = aiMsgs[aiMsgs.length - 1];
433
+ }
434
+ if (!target)
435
+ return;
436
+ const timeline = getStepTimeline(target);
437
+ if (!timeline)
438
+ return;
439
+ pushStepToTimeline(timeline, data.type, {
440
+ tool: data.tool || 'unknown',
441
+ args: data.args,
442
+ success: data.success,
443
+ output: data.output,
444
+ error: data.error,
445
+ });
322
446
  }
323
- function resetRendererState() {
324
- streamingMessageEl = null;
325
- streamingTextNode = null;
326
- streamingText = "";
327
- lastUserCommand = "";
328
- lastAiContent = "";
329
- if (scrollToBottomTimer) {
330
- clearTimeout(scrollToBottomTimer);
331
- scrollToBottomTimer = null;
332
- }
447
+ // ---------------------------------------------------------------------------
448
+ // 重置模块状态 (切频道时调用)
449
+ // ---------------------------------------------------------------------------
450
+ export function resetRendererState() {
451
+ streamingMessageEl = null;
452
+ streamingTextNode = null;
453
+ streamingText = '';
454
+ lastUserCommand = '';
455
+ lastAiContent = '';
456
+ if (scrollToBottomTimer) {
457
+ clearTimeout(scrollToBottomTimer);
458
+ scrollToBottomTimer = null;
459
+ }
333
460
  }
334
- const MessageRenderer = {
335
- addMessage,
336
- handleStreamTokenEvent,
337
- finalizeTimelineAsMessage,
338
- handleStepEvent,
339
- escapeHtml,
340
- getMessagesContainerForCurrent,
341
- resetRendererState
461
+ export const MessageRenderer = {
462
+ addMessage,
463
+ handleStreamTokenEvent,
464
+ finalizeTimelineAsMessage,
465
+ handleStepEvent,
466
+ escapeHtml,
467
+ getMessagesContainerForCurrent,
468
+ resetRendererState,
342
469
  };
343
- if (typeof window !== "undefined") {
344
- window.MR = MessageRenderer;
470
+ if (typeof window !== 'undefined') {
471
+ window.MR = MessageRenderer;
345
472
  }
346
- export {
347
- MessageRenderer,
348
- addMessage,
349
- escapeHtml,
350
- finalizeTimelineAsMessage,
351
- getMessagesContainerForCurrent,
352
- handleStepEvent,
353
- handleStreamTokenEvent,
354
- hasStreamingText,
355
- resetRendererState
356
- };