@bolloon/bolloon-agent 0.2.6 → 0.2.7

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.
@@ -26,65 +26,137 @@ export function segmentChatReply(reply, ctx) {
26
26
  return [];
27
27
  const segments = [];
28
28
  let remaining = reply;
29
- // === 1. <think>...</think> (内容保留, wrap think segment) ===
29
+ // === 1. 启发式: 开头"让我.../First I'll.../I should..." 句子进 think ===
30
+ // LLM 偶尔不写 <think> 标签但直接出思考. 启发式: 文本首句以这些模式开头 → think
31
+ // 必须在 step 1 (显式 <think> 切分) 之前 — 因为显式切分 push 完会反序
32
+ if (!remaining.startsWith('<think>')) {
33
+ remaining = extractLeadingThinking(remaining, segments);
34
+ }
35
+ // === 2. 切 <think>...</think> (内容保留, wrap 成 think segment) ===
30
36
  remaining = extractAndPush('think', remaining, segments, /<think>([\s\S]*?)<\/think>/g);
31
- // === 2. 切 <environment_details>...</environment_details> ===
37
+ // === 3. 切 <environment_details>...</environment_details> ===
32
38
  remaining = extractAndPush('env_details', remaining, segments, /<environment_details>([\s\S]*?)<\/environment_details>/g);
33
- // === 3. 切 <final gen>...</final gen> 或单 marker ===
34
- // final 标记后面是最终答案. 整个切出, 只留 marker 之前的内容作为 text.
39
+ // === 4. 切 tool_call 标记 (核心: 完全去掉, 不让前端看到) ===
40
+ // 2026-07-01 修: 这步必须在 final 之前 final 标记之前的 tool_call
41
+ // "LLM 在 final 之前还在调工具", 这部分 content 应进 text (中间过程), 不应进 final.
42
+ remaining = stripToolCallMarkers(remaining, segments, ctx);
43
+ // === 5. 过滤 LLM 填充词 ("好了"/"完成"/"可以" 等单句 text 不上屏) ===
44
+ if (remaining.trim()) {
45
+ remaining = filterFillerText(remaining);
46
+ }
47
+ // === 6. final 之前的 text 段 (中间对话) push — 必须在 final push 之前 ===
48
+ // 注意: 这时 remaining 还含 <final gen> 标记 + 标记后内容. 切 final 前先把 <final gen> 之后内容清掉
49
+ // 实际: 切 final 后再切 text 更清楚
35
50
  const finalIdx = remaining.indexOf('<final gen>');
51
+ let beforeFinalText = remaining;
52
+ if (finalIdx >= 0) {
53
+ beforeFinalText = remaining.substring(0, finalIdx);
54
+ }
55
+ const textContent = beforeFinalText.trim();
56
+ if (textContent) {
57
+ segments.push({ type: 'text', content: textContent });
58
+ }
59
+ // === 7. 切 <final gen>...</final gen> (永远最后 push, 渲染时在最后) ===
36
60
  if (finalIdx >= 0) {
37
61
  const afterFinal = remaining.substring(finalIdx + '<final gen>'.length).trim();
38
62
  if (afterFinal) {
39
63
  segments.push({ type: 'final', content: afterFinal });
40
64
  }
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
65
  }
51
66
  return segments;
52
67
  }
53
- /** 提取正则匹配的 group(1) 内容, 包装为 segment; 原文位置去掉 */
68
+ /**
69
+ * 修 extractAndPush 的 reverse 删除 bug (2026-07-01)
70
+ * 之前: 用 mutate 后的 next 算切片, idx 错位 → 多 match 时删除错
71
+ * 现在: 用 lastIndex 累加在原文上标记删除区, 最后一次性 slice
72
+ */
54
73
  function extractAndPush(type, text, out, re) {
55
- // 关键是保留顺序. 用 exec 循环 + lastIndex 累加
56
74
  const matches = [];
57
- let m;
58
- // 复制正则避免 lastIndex stateful 污染
59
75
  const localRe = new RegExp(re.source, re.flags);
76
+ let m;
60
77
  while ((m = localRe.exec(text)) !== null) {
78
+ const content = m[1] ? m[1].trim() : '';
61
79
  matches.push({
62
- idx: m.index,
63
- len: m[0].length,
64
- content: m[1].trim(),
80
+ start: m.index,
81
+ end: m.index + m[0].length,
82
+ content,
65
83
  });
66
84
  if (m[0].length === 0)
67
- localRe.lastIndex++; // 防无限 loop
85
+ localRe.lastIndex++;
68
86
  }
69
87
  if (matches.length === 0)
70
88
  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
89
+ // push 正向 (保顺序)
81
90
  for (const m of matches) {
82
91
  if (m.content)
83
92
  out.push({ type, content: m.content });
84
93
  }
94
+ // 删除: 在原文上基于 start/end 切片, 不在 mutate 后算 idx
95
+ let next = '';
96
+ let cursor = 0;
97
+ for (const m of matches) {
98
+ next += text.substring(cursor, m.start);
99
+ cursor = m.end;
100
+ }
101
+ next += text.substring(cursor);
85
102
  return next;
86
103
  }
87
- /** 去掉 tool_call 标记 (XML / JSON / Sentinel 各种形式), 留下纯 text. 识别到的 wrap 成 tool_call segment. */
104
+ /**
105
+ * 启发式: 提取开头的"思考"句子 (2026-07-01 新增)
106
+ * 模式: 文本开头遇到这些起手式, 整句 (到下一个 \n 或 . 或 句号) 切到 think 段
107
+ * 例子:
108
+ * "让我先想想用户的需求" + 正文 → think(让我先想想用户的需求) + 正文
109
+ * "First I'll check the project structure" → think(...)
110
+ * 不命中 → 整段保留, 走 text
111
+ */
112
+ function extractLeadingThinking(text, out) {
113
+ // 模式 1: 显式/隐式思考句首 (一句到第一个 \n 或 \.\?\! 结束)
114
+ // "让我..." / "我先..." / "先来..." / "接下来..." / "First, ..." / "I'll ..."
115
+ // 整句进 think, 后续 content 进 text
116
+ // 模式 2: 整行都是思考 (一行 \n 间隔)
117
+ const firstLineEnd = text.indexOf('\n');
118
+ const firstLine = firstLineEnd === -1 ? text : text.substring(0, firstLineEnd);
119
+ const rest = firstLineEnd === -1 ? '' : text.substring(firstLineEnd + 1);
120
+ // 中文/英文思考起手词
121
+ const thinkingStartRe = /^(让我|我先|我应该|先来|先|接下来|好的[,,]?\s*我|让我先|先看看|让我看看|思考|考虑)/;
122
+ const enThinkingStartRe = /^(Let me|I'll|I will|First,|Next,|Now,|So,|Alright[,.]\s+(?:let me|I'll|I will))/i;
123
+ if (firstLine.trim().length === 0) {
124
+ return text; // 空开头不动
125
+ }
126
+ // 单行启发式: 整行 < 120 字符 + 起始 match → 整行进 think
127
+ if (firstLine.length <= 120 && (thinkingStartRe.test(firstLine) || enThinkingStartRe.test(firstLine))) {
128
+ out.push({ type: 'think', content: firstLine.trim() });
129
+ return rest;
130
+ }
131
+ // 多行启发式: 第一段 (到第一个空行) 是思考
132
+ if (firstLine.length <= 80 && (thinkingStartRe.test(firstLine) || enThinkingStartRe.test(firstLine))) {
133
+ out.push({ type: 'think', content: firstLine.trim() });
134
+ return rest;
135
+ }
136
+ return text;
137
+ }
138
+ /**
139
+ * 过滤 LLM 填充词 (2026-07-01 新增)
140
+ * 单独的"好了"/"完成"/"可以"/"答完了"等单句不显示在气泡
141
+ * 配合 segmentChatReply 步骤 6
142
+ */
143
+ function filterFillerText(text) {
144
+ // 整行 = filler 词 → 整行丢
145
+ const lines = text.split('\n');
146
+ const filtered = [];
147
+ for (const line of lines) {
148
+ const t = line.trim();
149
+ if (!t)
150
+ continue;
151
+ // 匹配: 整行 = 填充词 (可带标点)
152
+ if (/^(好|好了|好的|完成|完成了|任务完成|可以|可以了|答完了|说完了|就这样|完了|done|ok|OK|Okay|okay|alright|fine|let me check|我来)\.?$/i.test(t)) {
153
+ continue;
154
+ }
155
+ filtered.push(line);
156
+ }
157
+ return filtered.join('\n').trim();
158
+ }
159
+ /** 提取正则匹配的 group(1) 内容, 包装为 segment; 原文位置去掉 */
88
160
  function stripToolCallMarkers(text, out, ctx) {
89
161
  // 1. 解析 [TOOL_CALL]...[/TOOL_CALL]
90
162
  let result = text.replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, (m) => {
@@ -149,6 +221,42 @@ function stripToolCallMarkers(text, out, ctx) {
149
221
  });
150
222
  // 7. [Function calling]...[/Function calling] 旧 bolloon
151
223
  result = result.replace(/\[Function[^\]]*\]\s*/g, '');
224
+ // 8. (2026-07-01) 未闭合的 tool_call 起始标签 (LLM 偶输出截断)
225
+ // 例子: "<tool_call><invoke name="shell_exec"><command>ls</command></invoke>" 缺少 </tool_call>
226
+ // 上面 1-7 步的正则都要求完整闭合对, 不闭合会留残文.
227
+ // 修法: 扫到孤立起始标签 (后面没有匹配的闭合) → 整段删到 \n\n 或 end
228
+ result = stripUnclosedToolCallTags(result);
229
+ return result;
230
+ }
231
+ /**
232
+ * 删未闭合的 tool_call 起始标签 + 后面到段结束 (2026-07-01)
233
+ * 例子: "<tool_call><invoke name="shell_exec"><command>ls</command></invoke>"
234
+ * → 检查每个起始标签 (<tool_call>, <invoke, <function_calls, [TOOL_CALL])
235
+ * → 如果没找到对应闭合, 删整段
236
+ */
237
+ function stripUnclosedToolCallTags(text) {
238
+ const startTags = [
239
+ { tag: '<tool_call>', closeTag: '</tool_call>', openRe: /<tool_call>/g },
240
+ { tag: '<invoke ', closeTag: '</invoke>', openRe: /<invoke\s+name=["']([\w]+)["']>/g },
241
+ { tag: '<function_calls>', closeTag: '</function_calls>', openRe: /<function_calls>/g },
242
+ { tag: '[TOOL_CALL]', closeTag: '[/TOOL_CALL]', openRe: /\[TOOL_CALL\]/g },
243
+ ];
244
+ let result = text;
245
+ for (const { tag, closeTag, openRe } of startTags) {
246
+ let m;
247
+ while ((m = openRe.exec(result)) !== null) {
248
+ // 检查 startIdx 之后是否还有 closeTag
249
+ const tail = result.substring(m.index);
250
+ const closeIdx = tail.indexOf(closeTag);
251
+ if (closeIdx === -1) {
252
+ // 未闭合 — 删起始标签 + 之后所有内容 (到 \n\n 或 end)
253
+ const nextBlank = result.indexOf('\n\n', m.index);
254
+ const endIdx = nextBlank === -1 ? result.length : nextBlank;
255
+ result = result.substring(0, m.index) + result.substring(endIdx);
256
+ break; // 重新开始本 tag 扫描
257
+ }
258
+ }
259
+ }
152
260
  return result;
153
261
  }
154
262
  /** 从 <invoke>...</invoke> inner XML 抽 <command> / <args> / <parameter> 简易 args */
@@ -1409,6 +1409,12 @@ export async function createWebServer(port = 3000, options = {}) {
1409
1409
  next();
1410
1410
  });
1411
1411
  app.use(express.static(webRoot));
1412
+ // 2026-07-01 (v0.2.6 修复): 前端 message-renderer import 了 src/agents/chat-segmenter.js,
1413
+ // 浏览器加载时按相对路径解析成 /agents/chat-segmenter.js. 这个路径在 webRoot (dist/web) 之外.
1414
+ // 解决: 把 dist/agents 也 mount 到 /agents 静态路径, 让前端 import 解析成功.
1415
+ // 这条不破坏 segmenter 内部 import (Node 走文件系统, 浏览器走 HTTP).
1416
+ const agentsRoot = path.join(webRoot, '..', 'agents');
1417
+ app.use('/agents', express.static(agentsRoot));
1412
1418
  app.get('/', (req, res) => {
1413
1419
  res.sendFile(join(webRoot, 'index.html'));
1414
1420
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",