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 +77 -6
- package/dist/ui/prompt.js +34 -8
- package/package.json +1 -1
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
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
|
|
96
|
-
|
|
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 =
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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':
|