@bolloon/bolloon-agent 0.2.4 → 0.2.6

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * chat-segmenter — 把 LLM 原始输出切成结构化 segments (前后端分离核心)
3
+ *
4
+ * 2026-07-01 (v0.2.6): 抽到独立模块. 目标: 前端只渲染, 不做正则.
5
+ *
6
+ * 设计:
7
+ * 后端跑完 LLM 一轮后, 调 segmentChatReply() 把 reply 切成:
8
+ * [{type: 'think', content: '...'}, {type: 'text', content: '...'}, ...]
9
+ * 然后序列化发给前端, 前端按 type 渲染 — 不知道任何 <invoke> / <function_calls>
10
+ * /<tool_call> / {tool:...} 这些 LLM 输出格式细节.
11
+ *
12
+ * 与 parse-tool-call.ts 的区别:
13
+ * - parse-tool-call: 找 **第一个** tool_call, 提取 name+args (用于 dispatch)
14
+ * - chat-segmenter: 切**整段** reply 为展示 segments, **完全去掉** tool_call 标记
15
+ * 复用 parse-tool-call 的 tool_call 检测逻辑保证一致性
16
+ *
17
+ * 返回类型: ChatSegment[] 序列化 JSON 给前端:
18
+ * {type: 'think'|'text'|'env_details'|'tool_call'|'final', content?: string, tool?: {...}}
19
+ *
20
+ * 顺序按 LLM 输出流排, 不重排.
21
+ */
22
+ import { parseToolCall } from './parse-tool-call.js';
23
+ /** 切分 LLM 输出. */
24
+ export function segmentChatReply(reply, ctx) {
25
+ if (!reply)
26
+ return [];
27
+ const segments = [];
28
+ let remaining = reply;
29
+ // === 1. 切 <think>...</think> (内容保留, wrap 成 think segment) ===
30
+ remaining = extractAndPush('think', remaining, segments, /<think>([\s\S]*?)<\/think>/g);
31
+ // === 2. 切 <environment_details>...</environment_details> ===
32
+ remaining = extractAndPush('env_details', remaining, segments, /<environment_details>([\s\S]*?)<\/environment_details>/g);
33
+ // === 3. 切 <final gen>...</final gen> 或单 marker ===
34
+ // final 标记后面是最终答案. 整个切出, 只留 marker 之前的内容作为 text.
35
+ const finalIdx = remaining.indexOf('<final gen>');
36
+ if (finalIdx >= 0) {
37
+ const afterFinal = remaining.substring(finalIdx + '<final gen>'.length).trim();
38
+ if (afterFinal) {
39
+ segments.push({ type: 'final', content: afterFinal });
40
+ }
41
+ remaining = remaining.substring(0, finalIdx).trim();
42
+ }
43
+ // === 4. 切 tool_call 标记 (核心: 完全去掉, 不让前端看到) ===
44
+ // parse-tool-call 知道怎么定位. 我们做类似但收集 segment.
45
+ remaining = stripToolCallMarkers(remaining, segments, ctx);
46
+ // === 5. 剩余当 text ===
47
+ const textContent = remaining.trim();
48
+ if (textContent) {
49
+ segments.push({ type: 'text', content: textContent });
50
+ }
51
+ return segments;
52
+ }
53
+ /** 提取正则匹配的 group(1) 内容, 包装为 segment; 原文位置去掉 */
54
+ function extractAndPush(type, text, out, re) {
55
+ // 关键是保留顺序. 用 exec 循环 + lastIndex 累加
56
+ const matches = [];
57
+ let m;
58
+ // 复制正则避免 lastIndex stateful 污染
59
+ const localRe = new RegExp(re.source, re.flags);
60
+ while ((m = localRe.exec(text)) !== null) {
61
+ matches.push({
62
+ idx: m.index,
63
+ len: m[0].length,
64
+ content: m[1].trim(),
65
+ });
66
+ if (m[0].length === 0)
67
+ localRe.lastIndex++; // 防无限 loop
68
+ }
69
+ if (matches.length === 0)
70
+ return text;
71
+ // 反向遍历删除, 同时按原顺序 push
72
+ let next = text;
73
+ for (let i = matches.length - 1; i >= 0; i--) {
74
+ const { idx, len, content } = matches[i];
75
+ if (!content)
76
+ continue;
77
+ next = next.substring(0, idx) + next.substring(idx + len);
78
+ // 但 push 是正向, 倒着算 position
79
+ }
80
+ // 重新正向 push
81
+ for (const m of matches) {
82
+ if (m.content)
83
+ out.push({ type, content: m.content });
84
+ }
85
+ return next;
86
+ }
87
+ /** 去掉 tool_call 标记 (XML / JSON / Sentinel 各种形式), 留下纯 text. 识别到的 wrap 成 tool_call segment. */
88
+ function stripToolCallMarkers(text, out, ctx) {
89
+ // 1. 解析 [TOOL_CALL]...[/TOOL_CALL]
90
+ let result = text.replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, (m) => {
91
+ const parsed = parseToolCall(m, { tools: ctx.knownToolNames });
92
+ if (parsed)
93
+ out.push({ type: 'tool_call', tool: { name: parsed.name, args: parsed.args } });
94
+ return '';
95
+ });
96
+ // 2.<tool_call>...</tool_call> (OpenAI Hermes)
97
+ result = result.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, (m) => {
98
+ const parsed = parseToolCall(m, { tools: ctx.knownToolNames });
99
+ if (parsed)
100
+ out.push({ type: 'tool_call', tool: { name: parsed.name, args: parsed.args } });
101
+ return '';
102
+ });
103
+ // 3. <invoke name="X">...</invoke> (minimax/Hermes)
104
+ result = result.replace(/<invoke\s+name=["']([\w]+)["']>([\s\S]*?)<\/invoke>/g, (_m, name, inner) => {
105
+ if (ctx.knownToolNames.has(name)) {
106
+ const args = extractSimpleArgs(inner);
107
+ out.push({ type: 'tool_call', tool: { name, args } });
108
+ }
109
+ return '';
110
+ });
111
+ // 4. <function_calls>...</function_calls> 整块
112
+ result = result.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, (m) => {
113
+ const parsed = parseToolCall(m, { tools: ctx.knownToolNames });
114
+ if (parsed)
115
+ out.push({ type: 'tool_call', tool: { name: parsed.name, args: parsed.args } });
116
+ return '';
117
+ });
118
+ // 5. JSON 形式 {"name": "X", "arguments": {...}}
119
+ result = result.replace(/\{\s*"name"\s*:\s*"([\w]+)"\s*,\s*"(?:arguments|input|args|params)"\s*:\s*(\{[\s\S]*?\})\s*\}/g, (_m, name, argsJson) => {
120
+ if (!ctx.knownToolNames.has(name))
121
+ return '';
122
+ let args = {};
123
+ try {
124
+ const parsed = JSON.parse(argsJson);
125
+ if (parsed && typeof parsed === 'object') {
126
+ args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
127
+ }
128
+ }
129
+ catch { }
130
+ out.push({ type: 'tool_call', tool: { name, args } });
131
+ return '';
132
+ });
133
+ // 6. tool => "X", args => {...} (Perplexity-style 内部)
134
+ result = result.replace(/\{\s*tool\s*=>\s*["']([\w]+)["']\s*(?:,\s*args\s*=>\s*(\{[\s\S]*?\}))?\s*\}/g, (_m, name, argsJson) => {
135
+ if (!ctx.knownToolNames.has(name))
136
+ return '';
137
+ let args = {};
138
+ if (argsJson) {
139
+ try {
140
+ const parsed = JSON.parse(argsJson);
141
+ if (parsed && typeof parsed === 'object') {
142
+ args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
143
+ }
144
+ }
145
+ catch { }
146
+ }
147
+ out.push({ type: 'tool_call', tool: { name, args } });
148
+ return '';
149
+ });
150
+ // 7. [Function calling]...[/Function calling] 旧 bolloon
151
+ result = result.replace(/\[Function[^\]]*\]\s*/g, '');
152
+ return result;
153
+ }
154
+ /** 从 <invoke>...</invoke> inner XML 抽 <command> / <args> / <parameter> 简易 args */
155
+ function extractSimpleArgs(inner) {
156
+ const args = {};
157
+ // <parameter name="X">value</parameter>
158
+ const paramRe = /<parameter\s+name=["'](\w+)["']>([\s\S]*?)<\/parameter>/g;
159
+ let m;
160
+ while ((m = paramRe.exec(inner)) !== null) {
161
+ args[m[1]] = m[2].trim();
162
+ }
163
+ // <param name="X">value</param>
164
+ if (Object.keys(args).length === 0) {
165
+ const shortRe = /<param\s+name=["'](\w+)["']>([\s\S]*?)<\/param>/g;
166
+ while ((m = shortRe.exec(inner)) !== null) {
167
+ args[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
168
+ }
169
+ }
170
+ // <command>X</command> + <args>Y</args>
171
+ if (Object.keys(args).length === 0) {
172
+ const cmdM = inner.match(/<command>([\s\S]*?)<\/command>/);
173
+ const argsM = inner.match(/<args>([\s\S]*?)<\/args>/);
174
+ if (cmdM) {
175
+ const cmd = cmdM[1].trim();
176
+ // auto-split (跟 parse-tool-call 一致: 含空格 → split)
177
+ if (cmd.includes(' ') && !argsM) {
178
+ const parts = cmd.split(/\s+/);
179
+ args.command = parts[0];
180
+ args.args = parts.slice(1).join(' ');
181
+ }
182
+ else {
183
+ args.command = cmd;
184
+ }
185
+ }
186
+ if (argsM)
187
+ args.args = argsM[1].trim();
188
+ }
189
+ return args;
190
+ }
package/dist/cli-entry.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { spawn } from 'child_process';
14
14
  import * as path from 'path';
15
15
  import * as fs from 'fs';
16
+ import { fileURLToPath } from 'url';
16
17
  const isWindows = process.platform === 'win32';
17
18
  // ANSI 颜色
18
19
  const RESET = '\x1b[0m';
@@ -66,8 +67,11 @@ ${BOLD}环境变量:${RESET}
66
67
  `);
67
68
  }
68
69
  function getDistDir() {
69
- // 对于打包后的应用,__dirname dist 目录
70
- return __dirname;
70
+ // 2026-07-01: ESM 模块无 __dirname, import.meta.url + path.dirname 拿当前文件所在目录.
71
+ // 对于打包后的应用, 这是 dist 目录; 对于 tsx 跑 src/index.ts 时, 是 src/ 目录
72
+ // (后续 getMainScript 会优先用 dist/cli-entry.js, 不依赖这里的返回值)
73
+ const __filename_esm = fileURLToPath(import.meta.url);
74
+ return path.dirname(__filename_esm);
71
75
  }
72
76
  function getMainScript() {
73
77
  const distDir = getDistDir();
@@ -0,0 +1,103 @@
1
+ /**
2
+ * input-validator — web 接口输入验证 (v0.2.5)
3
+ *
4
+ * 2026-07-01: 抽到独立模块, claude code / 前端都能 import 验证.
5
+ * - 后端用: POST /api/validate-input 调 validate()
6
+ * - 前端用: 实时 UI 反馈 (发送前预校验, 不阻塞)
7
+ *
8
+ * 验证规则 (bolloon web 当前业务):
9
+ * - text 非空 (trim 后)
10
+ * - text 长度 <= MAX_TEXT_LENGTH
11
+ * - text 不包含 NUL / 不可打印控制字符
12
+ * - channelId 非空
13
+ * - channelName (新建频道) 长度 <= MAX_NAME_LENGTH
14
+ * - 不含 SQL/XSS payload 触发 (sanitize-only: 不是阻止, 是 warn)
15
+ *
16
+ * 设计: 纯函数 + 返回结构化 ValidationResult.
17
+ * { ok: true, reason?, severity? }
18
+ * 严重等级: 'info' | 'warn' | 'block'
19
+ */
20
+ export const MAX_TEXT_LENGTH = 16_000; // 大约 4K tokens, 单条 user 消息够用
21
+ export const MAX_NAME_LENGTH = 200; // channel name 上限
22
+ export const MAX_AGENT_ID_LENGTH = 200;
23
+ /** 校验单条 /message 请求 (text + channelId) */
24
+ export function validateMessageInput(input) {
25
+ // 1. channelId
26
+ if (typeof input.channelId !== 'string' || input.channelId.trim() === '') {
27
+ return { ok: false, severity: 'block', reason: 'channelId 必填' };
28
+ }
29
+ if (input.channelId.length > 256) {
30
+ return { ok: false, severity: 'block', reason: `channelId 长度超过 256 字符 (${input.channelId.length})` };
31
+ }
32
+ // 2. text
33
+ if (typeof input.text !== 'string') {
34
+ return { ok: false, severity: 'block', reason: 'text 必填且必须是字符串' };
35
+ }
36
+ const trimmed = input.text.trim();
37
+ if (trimmed === '') {
38
+ return { ok: false, severity: 'block', reason: 'text 不能为空 (trim 后)' };
39
+ }
40
+ if (trimmed.length > MAX_TEXT_LENGTH) {
41
+ return {
42
+ ok: false,
43
+ severity: 'block',
44
+ reason: `text 长度 ${trimmed.length} 超过最大 ${MAX_TEXT_LENGTH} 字符`,
45
+ };
46
+ }
47
+ // 3. 控制字符检测 — 拒绝 NUL + 不可打印 char (< 0x20 除了 \n \r \t)
48
+ // eslint-disable-next-line no-control-regex
49
+ const controlCharRe = /[\x00-\x08\x0B-\x0C\x0E-\x1F]/g;
50
+ const controlMatches = trimmed.match(controlCharRe);
51
+ if (controlMatches && controlMatches.length > 0) {
52
+ return {
53
+ ok: false,
54
+ severity: 'block',
55
+ reason: `text 含 ${controlMatches.length} 个不可打印控制字符`,
56
+ };
57
+ }
58
+ // 4. 通用安全 check — 大段 HTML/JS 注入 (sanitize 是另一层, 这里只 warn)
59
+ const htmlRe = /<\/?[a-z][\s\S]*>/i;
60
+ if (htmlRe.test(trimmed) && trimmed.length > 500) {
61
+ return {
62
+ ok: true,
63
+ severity: 'warn',
64
+ reason: 'text 包含 HTML 标签 (>500 字符), 确认发送前预览',
65
+ cleaned: trimmed,
66
+ };
67
+ }
68
+ return { ok: true, severity: 'info', cleaned: trimmed };
69
+ }
70
+ /** 校验 channel 创建输入 */
71
+ export function validateChannelInput(input) {
72
+ if (typeof input.name !== 'string' || input.name.trim() === '') {
73
+ return { ok: false, severity: 'block', reason: 'name 必填' };
74
+ }
75
+ const trimmedName = input.name.trim();
76
+ if (trimmedName.length > MAX_NAME_LENGTH) {
77
+ return {
78
+ ok: false,
79
+ severity: 'block',
80
+ reason: `name 长度 ${trimmedName.length} 超过最大 ${MAX_NAME_LENGTH}`,
81
+ };
82
+ }
83
+ if (typeof input.agentId === 'string' && input.agentId.length > MAX_AGENT_ID_LENGTH) {
84
+ return {
85
+ ok: false,
86
+ severity: 'block',
87
+ reason: `agentId 长度 ${input.agentId.length} 超过最大 ${MAX_AGENT_ID_LENGTH}`,
88
+ };
89
+ }
90
+ return { ok: true, severity: 'info', cleaned: trimmedName };
91
+ }
92
+ const BOOT_TIME = Date.now();
93
+ export function healthCheck(version) {
94
+ return {
95
+ ok: true,
96
+ version,
97
+ uptime_sec: Math.floor((Date.now() - BOOT_TIME) / 1000),
98
+ validators_loaded: [
99
+ 'validateMessageInput',
100
+ 'validateChannelInput',
101
+ ],
102
+ };
103
+ }
@@ -6,6 +6,33 @@ import * as fs from 'fs/promises';
6
6
  import * as fsSync from 'fs';
7
7
  import * as path from 'path';
8
8
  import * as os from 'os';
9
+ import { validateMessageInput, validateChannelInput, healthCheck, } from './input-validator.js';
10
+ import { segmentChatReply } from '../agents/chat-segmenter.js';
11
+ // 读自身 package.json 拿 version (health endpoint 用)
12
+ // 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
13
+ let cachedVersion = null;
14
+ function getPackageVersion() {
15
+ if (cachedVersion)
16
+ return cachedVersion;
17
+ try {
18
+ const here = fileURLToPath(import.meta.url);
19
+ const hereDir = path.dirname(here);
20
+ // 试 ../package.json (相对 dist/web/) 或 ../../package.json (相对 src/web/)
21
+ let raw = null;
22
+ for (const rel of ['../package.json', '../../package.json']) {
23
+ try {
24
+ raw = fsSync.readFileSync(path.join(hereDir, rel), 'utf-8');
25
+ break;
26
+ }
27
+ catch { }
28
+ }
29
+ cachedVersion = raw ? (JSON.parse(raw).version ?? '0.0.0') : 'unknown';
30
+ }
31
+ catch {
32
+ cachedVersion = 'unknown';
33
+ }
34
+ return cachedVersion;
35
+ }
9
36
  // 2026-06-17: 终端静默 — server.ts 高频 spam (LLM 流式每个 token 一次 broadcast,
10
37
  // 每次 P2P 收发 [v3]/[v3-meta]/[v3-cross]/[v3-friend] 各一次) 全部走 console.log proxy,
11
38
  // 默认 (BOLLOON_VERBOSE != '1') 完全不打;VERBOSE=1 时恢复.
@@ -1410,6 +1437,61 @@ export async function createWebServer(port = 3000, options = {}) {
1410
1437
  }
1411
1438
  app.get('/', serveStaticHtml('index.html', 'index.html not found; please run `npm run build:web`', 'index'));
1412
1439
  app.get('/api-config', serveStaticHtml('api-config.html', 'api-config.html not found; please run `npm run build:web`', 'api-config'));
1440
+ // 2026-07-01 (v0.2.5): 输入验证 + 健康检查 API
1441
+ // - POST /api/validate-input: 前端发送前预校验, 避免后端 reject
1442
+ // - GET /api/health: liveness probe + validators 清单
1443
+ // 这些端点不依赖 LLM / P2P, 永远可以响应.
1444
+ app.post('/api/validate-input', async (req, res) => {
1445
+ try {
1446
+ const body = req.body ?? {};
1447
+ const kind = body.kind || 'message';
1448
+ let result;
1449
+ if (kind === 'channel') {
1450
+ result = validateChannelInput({ name: body.name, agentId: body.agentId });
1451
+ }
1452
+ else {
1453
+ result = validateMessageInput({ text: body.text, channelId: body.channelId });
1454
+ }
1455
+ res.json({
1456
+ ok: result.ok,
1457
+ severity: result.severity ?? (result.ok ? 'info' : 'block'),
1458
+ reason: result.reason,
1459
+ cleaned: result.cleaned,
1460
+ kind,
1461
+ });
1462
+ }
1463
+ catch (e) {
1464
+ res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
1465
+ }
1466
+ });
1467
+ app.get('/api/health', (_req, res) => {
1468
+ res.json(healthCheck(getPackageVersion()));
1469
+ });
1470
+ // 2026-07-01 (v0.2.6): 前后端分离核心 — 后端切 LLM 输出为结构化 segments
1471
+ // - POST /api/segment-reply { reply, knownTools }
1472
+ // - 返回 ChatSegment[] (think / text / env_details / tool_call / final)
1473
+ // 前端拿到 segments 后只渲染, 不知道任何 LLM 格式 (minimax <invoke>, Hermes <tool_call>, Qwen function_calls, JSON 形式)
1474
+ app.post('/api/segment-reply', async (req, res) => {
1475
+ try {
1476
+ const body = req.body ?? {};
1477
+ const reply = typeof body.reply === 'string' ? body.reply : '';
1478
+ const knownToolsArr = Array.isArray(body.knownTools) ? body.knownTools : [];
1479
+ const knownToolNames = new Set(knownToolsArr.filter((s) => typeof s === 'string'));
1480
+ const segments = segmentChatReply(reply, { knownToolNames });
1481
+ res.json({
1482
+ ok: true,
1483
+ input_length: reply.length,
1484
+ segments,
1485
+ segments_count: segments.length,
1486
+ has_think: segments.some(s => s.type === 'think'),
1487
+ has_tool_call: segments.some(s => s.type === 'tool_call'),
1488
+ has_final: segments.some(s => s.type === 'final'),
1489
+ });
1490
+ }
1491
+ catch (e) {
1492
+ res.status(400).json({ ok: false, reason: e?.message ?? 'parse error' });
1493
+ }
1494
+ });
1413
1495
  // 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
1414
1496
  app.use((err, req, res, _next) => {
1415
1497
  console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
@@ -40,6 +40,9 @@
40
40
  // 类型定义
41
41
  // ---------------------------------------------------------------------------
42
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';
43
46
  // ---------------------------------------------------------------------------
44
47
  // 模块私有状态
45
48
  // ---------------------------------------------------------------------------
@@ -117,55 +120,63 @@ export function addMessage(content, type, save = true, container, usedJudgmentId
117
120
  }
118
121
  const div = document.createElement('div');
119
122
  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));
123
+ // 2026-07-01 (v0.2.6 前后端分离): 改用 chat-segmenter 纯函数切 LLM 输出.
124
+ // 之前 8 行正则漏 minimax <invoke> / Qwen function_calls / <tool_call> / {tool:..}.
125
+ // 单一来源: src/agents/chat-segmenter.ts (server + client 共享).
126
+ // knownToolNames: 用 ctx 传入的注册表, fallback 空集 (不识别 tool_call 就 strip 不显示).
127
+ const knownToolNames = (ctx && ctx.knownToolNames) || new Set();
128
+ const segments = segmentChatReply(content, { knownToolNames });
129
+ // 没有可显示的 segment, 不上屏
130
+ if (segments.length === 0) {
131
+ return;
153
132
  }
154
- else if (cleanContent) {
155
- if (thinkContainer)
133
+ // 渲染各 segment (按 type 走不同容器)
134
+ let thinkContainer = null;
135
+ let renderedAny = false;
136
+ for (const seg of segments) {
137
+ if (seg.type === 'think' && seg.content) {
138
+ thinkContainer = buildThinkContainer(seg.content);
156
139
  div.appendChild(thinkContainer);
157
- div.appendChild(buildBubble(cleanContent, type));
140
+ renderedAny = true;
141
+ }
142
+ else if (seg.type === 'env_details' && seg.content) {
143
+ div.appendChild(buildEnvContainer(seg.content));
144
+ renderedAny = true;
145
+ }
146
+ else if (seg.type === 'text' && seg.content) {
147
+ if (thinkContainer)
148
+ div.appendChild(thinkContainer);
149
+ thinkContainer = null;
150
+ div.appendChild(buildBubble(seg.content, type));
151
+ renderedAny = true;
152
+ }
153
+ else if (seg.type === 'final' && seg.content) {
154
+ if (thinkContainer)
155
+ div.appendChild(thinkContainer);
156
+ thinkContainer = null;
157
+ // final 段渲染为特殊气泡 (顶部有标记, 表示 LLM 显式终止)
158
+ const finalEl = buildBubble(seg.content, type);
159
+ finalEl.classList.add('bubble-final');
160
+ div.appendChild(finalEl);
161
+ renderedAny = true;
162
+ }
163
+ else if (seg.type === 'tool_call' && seg.tool) {
164
+ // tool_call segment 不渲染文字 — 走 step-timeline (步骤状态条)
165
+ // 这里只记录到 ctx 让外层在 addMessage 后挂到 timeline
166
+ if (ctx && ctx.toolCallCallback) {
167
+ ctx.toolCallCallback(seg.tool, div);
168
+ }
169
+ renderedAny = true; // 即使没文字, tool_call 也算"有意义"
170
+ }
158
171
  }
159
- else {
160
- return; // 空内容, 不上屏
172
+ if (!renderedAny) {
173
+ return; // 没有渲染任何东西, 不上屏
161
174
  }
162
- // 纯文本用于复制按钮
163
- const rawContent = cleanContent
164
- .replace(/<[^>]+>/g, '')
165
- .replace(/&lt;/g, '<')
166
- .replace(/&gt;/g, '>')
167
- .replace(/&amp;/g, '&')
168
- .replace(/&nbsp;/g, ' ');
175
+ // 纯文本用于复制按钮 — 现在从 segments 拼, 不再依赖 cleanContent 变量
176
+ const rawContent = segments
177
+ .filter(s => s.type === 'text' || s.type === 'final')
178
+ .map(s => s.content || '')
179
+ .join('\n');
169
180
  // 时间
170
181
  const time = document.createElement('div');
171
182
  time.className = 'time';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",