mocode-ai 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/llm/index.js CHANGED
@@ -17,6 +17,14 @@ const RETRY_MAX_ATTEMPTS = 4;
17
17
  const RETRY_BASE_MS = 1000;
18
18
  const RETRY_MAX_MS = 30000;
19
19
  const RETRY_JITTER = 0.2;
20
+ /**
21
+ * 流式响应里出现的推理模型自创 `think` 标签(DeepSeek R1 / Qwen3 / 部分自训模型):
22
+ * 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
23
+ * 字符串,期间不调 onText(spinner 持续转 ⠹ 思考中…),也不写入可见 content(history 不被思考段污染)。
24
+ */
25
+ // 用 \u003c 表示 <,绕开本工具对 < 的处理(直接写 '<\u003cthink\u003e' 里 < 会被吃掉)。
26
+ const THINK_OPEN = '<think>';
27
+ const THINK_CLOSE = '</think>';
20
28
  let client = new OpenAI({
21
29
  baseURL: config.baseURL,
22
30
  apiKey: config.apiKey,
@@ -199,8 +207,13 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
199
207
  ...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
200
208
  ...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
201
209
  }, signal ? { signal } : undefined);
202
- let content = '';
203
- let hasContent = false;
210
+ // 流式 start end 标签过滤(见模块顶部 THINK_OPEN/CLOSE)。
211
+ // chunk 切分防御:buf 累积跨 chunk 边界,indexOf 扫描;为避免把跨 chunk 标签误判为
212
+ // 普通字符,buf 末尾为当前态保留 (label.length - 1) 个字符给下一 chunk 看。
213
+ let visibleContent = '';
214
+ let consumedAny = false;
215
+ let inThink = false;
216
+ let buf = '';
204
217
  let usage;
205
218
  const toolAcc = new Map();
206
219
  for await (const chunk of stream) {
@@ -216,9 +229,57 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
216
229
  if (!delta)
217
230
  continue; // 末尾 usage-only chunk 等无 delta
218
231
  if (delta.content) {
219
- content += delta.content;
220
- hasContent = true;
221
- handlers.onText?.(delta.content);
232
+ // 状态机切分 delta.content:inThink 外输出到 visibleContent + onText;
233
+ // inThink 内丢弃;标签起始/闭合用 indexOf 在 buf 里扫描。
234
+ // 末尾预留 (label.length - 1) 字符给下一 chunk 防切分误判。
235
+ buf += delta.content;
236
+ let i = 0;
237
+ while (true) {
238
+ if (inThink) {
239
+ // 思考段内,扫描 THINK_CLOSE;末尾预留 THINK_CLOSE.length - 1 防跨 chunk 切分
240
+ const endIdx = buf.indexOf(THINK_CLOSE, i);
241
+ if (endIdx === -1) {
242
+ // 思考段内未找到闭合;buf 短到不可能包含 </think> 时全丢(都是思考段内容),
243
+ // 否则留 (THINK_CLOSE.length - 1) 给下一 chunk 防跨边界切分。
244
+ const safeLen = buf.length >= THINK_CLOSE.length
245
+ ? buf.length - (THINK_CLOSE.length - 1)
246
+ : buf.length;
247
+ i = safeLen;
248
+ break;
249
+ }
250
+ inThink = false;
251
+ i = endIdx + THINK_CLOSE.length;
252
+ }
253
+ else {
254
+ // 普通段,扫描 THINK_OPEN;末尾预留 THINK_OPEN.length - 1 防跨 chunk 切分
255
+ const startIdx = buf.indexOf(THINK_OPEN, i);
256
+ if (startIdx === -1) {
257
+ // buf 短到不可能包含 <think> 时全输出(无 think 标签的普通模型不受影响);
258
+ // 否则留 (THINK_OPEN.length - 1) 给下一 chunk 防跨边界切分误判。
259
+ const safeLen = buf.length >= THINK_OPEN.length
260
+ ? buf.length - (THINK_OPEN.length - 1)
261
+ : buf.length;
262
+ const seg = buf.slice(i, safeLen);
263
+ if (seg) {
264
+ visibleContent += seg;
265
+ handlers.onText?.(seg);
266
+ consumedAny = true;
267
+ }
268
+ i = safeLen;
269
+ break;
270
+ }
271
+ // THINK_OPEN 之前的普通段:输出
272
+ if (startIdx > i) {
273
+ const seg = buf.slice(i, startIdx);
274
+ visibleContent += seg;
275
+ handlers.onText?.(seg);
276
+ consumedAny = true;
277
+ }
278
+ inThink = true;
279
+ i = startIdx + THINK_OPEN.length;
280
+ }
281
+ }
282
+ buf = buf.slice(i);
222
283
  }
223
284
  if (delta.tool_calls) {
224
285
  for (const tc of delta.tool_calls) {
@@ -241,6 +302,16 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
241
302
  }
242
303
  }
243
304
  }
305
+ // 防御:循环内 buf.slice 已把可确认部分消费;此处覆盖流末尾的"安全尾":
306
+ // - 普通段(stream 已结束,标签不会再出现):作为可见内容追加到 visibleContent(不再调 onText)
307
+ // - 思考段未闭合:丢弃,防 thinking 文本泄漏到 history
308
+ if (buf) {
309
+ if (!inThink) {
310
+ visibleContent += buf;
311
+ consumedAny = true;
312
+ }
313
+ buf = '';
314
+ }
244
315
  const toolCalls = [...toolAcc.entries()]
245
316
  .sort((a, b) => a[0] - b[0])
246
317
  .map(([, e]) => ({
@@ -249,7 +320,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
249
320
  arguments: e.arguments,
250
321
  }));
251
322
  return {
252
- content: hasContent ? content : null,
323
+ content: consumedAny ? visibleContent : null,
253
324
  toolCalls,
254
325
  usage,
255
326
  };
package/dist/ui/prompt.js CHANGED
@@ -84,24 +84,44 @@ export async function promptWithSlashMenu(opts) {
84
84
  let menuOpen = false;
85
85
  let selected = 0;
86
86
  let filtered = [];
87
+ const MENU_MAX_VISIBLE = 5;
88
+ let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
87
89
  let resolved = false;
88
90
  let resolve;
89
91
  let reject;
90
- /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。 */
92
+ /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
91
93
  function menuLines() {
92
94
  if (!menuOpen || filtered.length === 0)
93
95
  return [];
94
96
  const cols = layout.getGeo().cols;
95
- const maxName = Math.max(...filtered.map((c) => displayWidth(c.name)));
96
- return filtered.map((c, i) => {
97
+ const visibleCount = Math.min(MENU_MAX_VISIBLE, filtered.length);
98
+ // selected 在窗口内:selected 顶到上/下边界时才挪 menuTop
99
+ if (selected < menuTop)
100
+ menuTop = selected;
101
+ else if (selected >= menuTop + visibleCount)
102
+ menuTop = selected - visibleCount + 1;
103
+ const windowItems = filtered.slice(menuTop, menuTop + visibleCount);
104
+ const maxName = Math.max(...windowItems.map((c) => displayWidth(c.name)));
105
+ const hasMoreAbove = menuTop > 0;
106
+ const hasMoreBelow = menuTop + visibleCount < filtered.length;
107
+ return windowItems.map((c, i) => {
108
+ const globalIdx = menuTop + i;
97
109
  // 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
98
- const isSel = i === selected;
110
+ const isSel = globalIdx === selected;
99
111
  const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
100
112
  const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
101
113
  const name = padEndDisplay(c.name, maxName);
102
- const descW = cols - maxName - 5; // marker + 空格 + 2 间距
103
- const desc = descW > 0 ? truncateDisplay(c.desc, descW) : '';
104
- return `${marker} ${color}${name}${ui.reset} ${color}${desc}${ui.reset}`;
114
+ // 首/末附加滚动指示(▲/▼)而非替换整行
115
+ let desc = c.desc;
116
+ let scrollHint = '';
117
+ if (i === 0 && hasMoreAbove)
118
+ scrollHint = ' ▲';
119
+ if (i === windowItems.length - 1 && hasMoreBelow)
120
+ scrollHint = ' ▼';
121
+ const hintW = displayWidth(scrollHint);
122
+ const descW = cols - maxName - 5 - hintW; // marker + 空格 + 2 间距 + hint
123
+ const descStr = descW > 0 ? truncateDisplay(desc, descW) : '';
124
+ return `${marker} ${color}${name}${ui.reset} ${color}${descStr}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
105
125
  });
106
126
  }
107
127
  /** 当前光标在该行的显示列(供 layout 定位光标)。 */
@@ -203,12 +223,15 @@ export async function promptWithSlashMenu(opts) {
203
223
  if (cl === 0 && lines[0].startsWith('/')) {
204
224
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
205
225
  menuOpen = filtered.length > 0;
206
- if (selected >= filtered.length)
226
+ if (selected >= filtered.length) {
207
227
  selected = 0;
228
+ menuTop = 0;
229
+ }
208
230
  }
209
231
  else {
210
232
  filtered = [];
211
233
  menuOpen = false;
234
+ menuTop = 0;
212
235
  }
213
236
  }
214
237
  function redraw() {
@@ -283,6 +306,7 @@ export async function promptWithSlashMenu(opts) {
283
306
  menuOpen = false;
284
307
  filtered = [];
285
308
  selected = 0;
309
+ menuTop = 0;
286
310
  if (pasteTimer) {
287
311
  clearTimeout(pasteTimer);
288
312
  pasteTimer = null;
@@ -469,6 +493,8 @@ export async function promptWithSlashMenu(opts) {
469
493
  case 'escape':
470
494
  menuOpen = false;
471
495
  filtered = [];
496
+ selected = 0;
497
+ menuTop = 0;
472
498
  redraw();
473
499
  return;
474
500
  case 'left':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {