@wonanut/dsh-agent-lab 0.0.0-stage → 0.2.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/src/30-llm.js ADDED
@@ -0,0 +1,495 @@
1
+ /* ── 30-llm.js · 规则驱动的模拟 LLM(计划 + 流式输出) ──
2
+ *
3
+ * 这是教学用的「假模型」:完全离线、规则驱动、可复现。
4
+ * 它只做两件事:
5
+ * 1. labNextStep(ctx):决定下一步——要么直接给最终回答,要么发起一次工具调用;
6
+ * 2. labStreamAnswer(text, opts):把最终回答分块流式吐出来(真实节奏由 engine 注入)。
7
+ *
8
+ * 重要:模型绝不伪造工具结果。算术、时间、记忆都必须通过 20-tools.js 的
9
+ * LAB_TOOLS 真实执行,engine 把结果作为 lastTool 再传回模型。
10
+ */
11
+
12
+ /* ── 固定文案(中文、口语、尽量 ≤ 120 字) ── */
13
+ const LAB_LLM_EMPTY_TEXT =
14
+ '你好,我是教学用的迷你 Agent。可以试试「帮助」「算一下 (12+30)*3」或「记住 我的项目代号是 Orion」。';
15
+
16
+ const LAB_LLM_HELP_TEXT =
17
+ '我是教学用的迷你 Agent,能力有:算术、时间、掷骰子、随机选择、生成密码、单位换算、记住/回忆事实、查看工具和原理。' +
18
+ '可以试试:「算一下 (12+30)*3」「掷两个 20 面骰子」「从 火锅、烧烤、日料 里帮我选一个」「100 公里等于多少英里」。';
19
+
20
+ const LAB_LLM_GREETING_TEXT =
21
+ '你好!我是 Agent Lab 里规则驱动的迷你模型,离线、可复现。输入「算一下 (12+30)*3」' +
22
+ '看看 agent 如何调用工具,或输入「帮助」查看能力清单。';
23
+
24
+ const LAB_LLM_TOOLS_TEXT =
25
+ '我挂着 8 个工具:calculator(四则/幂运算)、clock(时间日期)、dice_roll(掷骰子)、' +
26
+ 'random_pick(随机选择)、password_generate(生成密码)、unit_convert(单位换算)、' +
27
+ 'memory_write(写长期记忆)、memory_search(检索记忆)。模型只决定调用哪个工具、传什么参数,结果由工具真实返回。';
28
+
29
+ const LAB_LLM_PRINCIPLE_TEXT =
30
+ '我是一个最小可解释的 agent 循环:接收输入 → 组装上下文 → 模型决定(直接回答或调用工具)→ ' +
31
+ '执行工具并把结果放回上下文 → 模型再决定,直到给出最终回答。我是规则驱动的模拟模型,不联网,' +
32
+ '但结构和真实 LLM Agent 一致。';
33
+
34
+ const LAB_LLM_FORGET_TEXT =
35
+ '想清空记忆的话,请在右侧「记忆」面板点「清空」,或调用 clearMemory()。' +
36
+ '我不会在对话里直接删记忆,避免误操作。';
37
+
38
+ /* ─────────────────────────────────────────────────────────────
39
+ * 意图识别
40
+ * ─────────────────────────────────────────────────────────────
41
+ */
42
+
43
+ function labLlmIsHelpQ(input) {
44
+ return /^\/help\b/i.test(input) || /帮助|你能做什么|能干什么|会做什么|怎么用|使用说明/.test(input);
45
+ }
46
+
47
+ function labLlmIsGreetingQ(input) {
48
+ return /^(你好|您好|嗨|哈喽|哈啰|hello|hi|hey|早上好|中午好|下午好|晚上好|在吗)[\s!!。.~~]*$/i.test(input);
49
+ }
50
+
51
+ function labLlmIsToolsQ(input) {
52
+ return (
53
+ /^工具[\s??。!!]*$/.test(input) ||
54
+ /(有哪些|有什么|几个|哪些).{0,4}工具|工具.{0,6}(列表|有哪些|是什么|作用|介绍)|tools?\b/i.test(input)
55
+ );
56
+ }
57
+
58
+ function labLlmIsPrincipleQ(input) {
59
+ return /你是谁|你是什么|怎么工作|如何工作|工作原理|原理|agent.{0,6}(循环|怎么|如何)|介绍.{0,4}自己|自我介绍一下|怎么运行|如何运行/i.test(
60
+ input,
61
+ );
62
+ }
63
+
64
+ function labLlmIsForgetQ(input) {
65
+ return /忘掉|忘记|清空记忆|清除记忆|删除记忆|删掉记忆/.test(input);
66
+ }
67
+
68
+ function labLlmIsCompactQ(input) {
69
+ return /压缩|compact|总结.{0,4}上下文|上下文.{0,6}(满|超|预算|压缩)/i.test(input);
70
+ }
71
+
72
+ function labLlmIsTimeQ(input) {
73
+ const trimmed = input.replace(/[。!??!\s]+$/g, '');
74
+ if (/^(时间|日期|今天|现在|几点|几号)$/.test(trimmed)) return true;
75
+ return /现在.{0,4}(几点|时间|日期|时刻)|当前.{0,4}(时间|日期)|今天.{0,4}(几号|日期|星期|周几)|(几点|几号|星期几|周几)(了|啊|呀)?[??]?$|\bnow\b|\bclock\b/i.test(
76
+ input,
77
+ );
78
+ }
79
+
80
+ function labLlmIsRecallQ(input) {
81
+ return /我的.{1,24}?(是什么|是多少|呢|叫什么|叫啥|多少)|你还记得|还记不记得|记得.{0,8}吗|回忆|记忆(里|中|库|面板)|^记忆$|查.{0,6}记忆|找.{0,6}记忆/.test(
82
+ input,
83
+ );
84
+ }
85
+
86
+ function labLlmIsWriteQ(input) {
87
+ return /记住|记一下|记一记|记下|记好|保存|存一下|存下|我的.{1,20}是/.test(input);
88
+ }
89
+
90
+ /* 「记住/保存」类意图:用于工具结果返回后的链式记忆写入 */
91
+ function labLlmWantsSave(input) {
92
+ return /记住|记下|记一下|记一记|保存|存一下|存下/.test(input);
93
+ }
94
+
95
+ /* 从「我的X是什么」等问题里抽出检索关键词;泛化的「你还记得什么」返回空串,
96
+ * 让 memory_search 返回最近记忆。 */
97
+ function labLlmRecallQuery(input) {
98
+ if (/^(你还记得什么|你还记得些什么|还记得什么|你记得什么|记得什么|记忆|回忆|回忆一下|你还记得吗|你记得吗)[??。!!\s]*$/.test(input)) {
99
+ return '';
100
+ }
101
+ const matched = /我的(.{1,24}?)(?:是什么|是多少|呢|叫什么|叫啥|多少)/.exec(input);
102
+ if (matched && matched[1].trim()) return matched[1].trim();
103
+ const stripped = input
104
+ .replace(/你还记得|还记不记得|记得|回忆|记忆|一下|帮我|请问|吗|呢|什么|查|找|的|是/g, ' ')
105
+ .replace(/\s+/g, ' ')
106
+ .trim();
107
+ return stripped || input;
108
+ }
109
+
110
+ /* 从表达式中抽出可以交给 calculator 的算式;抽不到返回 null */
111
+ function labLlmExtractExpression(input) {
112
+ /* 把非算式字符替换成空格,再去掉空格,得到连续算式 */
113
+ const cleaned = input.replace(/[^0-9+\-*/%^().\s]/g, ' ').replace(/\s+/g, '');
114
+ if (!cleaned) return null;
115
+ if (!/[0-9]/.test(cleaned)) return null;
116
+ if (!/[+\-*/%^]/.test(cleaned)) return null;
117
+ /* 括号必须配平 */
118
+ let depth = 0;
119
+ for (const ch of cleaned) {
120
+ if (ch === '(') depth += 1;
121
+ else if (ch === ')') {
122
+ depth -= 1;
123
+ if (depth < 0) return null;
124
+ }
125
+ }
126
+ if (depth !== 0) return null;
127
+ /* 不能以运算符结尾 / 开头(一元符号除外) */
128
+ if (/[+\-*/%^]$/.test(cleaned)) return null;
129
+ if (/^[*/%^]/.test(cleaned)) return null;
130
+ return cleaned;
131
+ }
132
+
133
+ /* 把「记住 …」类输入解析成 { key, value };解析失败返回 null */
134
+ function labLlmExtractMemory(input) {
135
+ let text = input;
136
+ /* 去掉开头的命令词 */
137
+ const stripped = text.replace(/^(请|麻烦|帮我|帮忙)?\s*(记住|记一下|记一记|记下|记好|保存一下|保存|存一下|存下)[::,,、\s]*/, '');
138
+ if (stripped !== text) text = stripped.trim();
139
+ if (!text) return null;
140
+ let matched = /^我的(.{1,20}?)是(.+)$/.exec(text);
141
+ if (matched) return { key: labLlmTrimPunct(matched[1]), value: labLlmTrimPunct(matched[2]) };
142
+ matched = /^我的(.{1,20}?)叫(.+)$/.exec(text);
143
+ if (matched) return { key: labLlmTrimPunct(matched[1]), value: labLlmTrimPunct(matched[2]) };
144
+ /* 「X 是 Y」这类自然表达(例如「项目 是 阿波罗」) */
145
+ matched = /^(.{1,20}?)\s*是\s*(.+)$/.exec(text);
146
+ if (matched) return { key: labLlmTrimPunct(matched[1]), value: labLlmTrimPunct(matched[2]) };
147
+ matched = /^(.{1,30}?)\s*(?:=|:|:)\s*(.+)$/.exec(text);
148
+ if (matched) return { key: labLlmTrimPunct(matched[1]), value: labLlmTrimPunct(matched[2]) };
149
+ /* 「记住:X」这类没有分隔符的内容,统一放到「备忘」下 */
150
+ return { key: '备忘', value: labLlmTrimPunct(text) };
151
+ }
152
+
153
+ function labLlmTrimPunct(text) {
154
+ return String(text == null ? '' : text)
155
+ .replace(/^[「『"'“\s]+|[」』"'”\s]+$/g, '')
156
+ .replace(/[。!!??;;,,、\s]+$/g, '')
157
+ .trim();
158
+ }
159
+
160
+ /* ─────────────────────────────────────────────────────────────
161
+ * 随机 / 实用工具意图
162
+ * ─────────────────────────────────────────────────────────────
163
+ */
164
+
165
+ const LAB_CN_NUMBERS = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
166
+
167
+ function labLlmChineseNumber(text) {
168
+ if (!text) return null;
169
+ if (text === '十') return 10;
170
+ if (text.length === 1 && LAB_CN_NUMBERS[text]) return LAB_CN_NUMBERS[text];
171
+ if (text.length === 2 && text[0] === '十' && LAB_CN_NUMBERS[text[1]]) return 10 + LAB_CN_NUMBERS[text[1]];
172
+ return null;
173
+ }
174
+
175
+ function labLlmIsDiceQ(input) {
176
+ return /掷骰|骰子|roll\s*(a\s*)?dice|\bdice\b|随机点数|抽个签|抽签/i.test(input);
177
+ }
178
+
179
+ function labLlmDiceArgs(input) {
180
+ let sides = 6;
181
+ let count = 1;
182
+ const dnd = /(\d+)\s*[dD](\d+)/.exec(input);
183
+ if (dnd) {
184
+ count = Math.max(1, Math.min(20, Number(dnd[1])));
185
+ sides = Math.max(2, Math.min(1000, Number(dnd[2])));
186
+ return { sides: sides, count: count };
187
+ }
188
+ const sidesMatch = /(\d+)\s*面/.exec(input);
189
+ if (sidesMatch) sides = Math.max(2, Math.min(1000, Number(sidesMatch[1])));
190
+ const countArabic = /(\d+)\s*(?:个|颗|次)/.exec(input);
191
+ if (countArabic) count = Math.max(1, Math.min(20, Number(countArabic[1])));
192
+ else {
193
+ const countChinese = /([一二两三四五六七八九十]+)\s*(?:个|颗|次)/.exec(input);
194
+ const parsed = labLlmChineseNumber(countChinese && countChinese[1]);
195
+ if (parsed) count = Math.max(1, Math.min(20, parsed));
196
+ }
197
+ return { sides: sides, count: count };
198
+ }
199
+
200
+ function labLlmIsPickQ(input) {
201
+ return /(帮我|给我|替我|帮忙)?\s*(选|挑|抽)\s*(一个|个|一下|哪|出)|随机选|随机挑|二选一|决定一下|选择困难|吃啥|吃什么/i.test(input);
202
+ }
203
+
204
+ function labLlmPickItems(input) {
205
+ let text = String(input || '');
206
+ const fromMatch = /从(.+?)(?:里|中|之间|当中|里面)?\s*(?:帮我|给我|替我|帮忙)?\s*(?:选|挑|抽)/.exec(text);
207
+ if (fromMatch) text = fromMatch[1];
208
+ else {
209
+ const tail = /(?:选|挑|抽)(?:一个|个|一下)?[::,,\s]*(.+)$/.exec(text);
210
+ if (tail) text = tail[1];
211
+ }
212
+ let items = [];
213
+ if (/还是/.test(text)) items = text.split(/还是/);
214
+ else items = text.split(/[、,,;;\/]|\s+和\s+|\s+或\s+/);
215
+ const cleaned = [];
216
+ for (let i = 0; i < items.length && cleaned.length < 20; i += 1) {
217
+ const item = labLlmTrimPunct(items[i])
218
+ .replace(/^(帮我|给我|替我|帮忙|请|麻烦)?\s*(选|挑|抽)(一个|个)?/, '')
219
+ .replace(/^(从|在)/, '')
220
+ .replace(/(里|中|之间|当中|里面)$/, '')
221
+ .replace(/(吧|呢|呗|啊)$/, '')
222
+ .trim();
223
+ if (item && cleaned.indexOf(item) < 0) cleaned.push(item);
224
+ }
225
+ return cleaned.length >= 2 ? cleaned : null;
226
+ }
227
+
228
+ function labLlmIsPasswordQ(input) {
229
+ return /(生成|创建|来|做|给我).{0,8}密码|随机密码|password/i.test(input);
230
+ }
231
+
232
+ function labLlmPasswordArgs(input) {
233
+ let length = 16;
234
+ const match = /(\d{1,2})\s*位/.exec(input);
235
+ if (match) length = Math.max(6, Math.min(64, Number(match[1])));
236
+ const asciiOnly = /纯字母|只要字母|不要数字|不含数字/.test(input);
237
+ const noSymbols = asciiOnly || /不要符号|不含符号|纯数字|只要数字/.test(input);
238
+ const symbols = !noSymbols;
239
+ const numbers = !(asciiOnly || /纯字母|只要字母/.test(input));
240
+ return { length: length, numbers: numbers, symbols: symbols };
241
+ }
242
+
243
+ function labLlmUnitArgs(input) {
244
+ const unitPattern = typeof LAB_UNIT_WORD_PATTERN === 'string' ? LAB_UNIT_WORD_PATTERN : '';
245
+ if (!unitPattern) return null;
246
+ const regex = new RegExp(
247
+ '([-\\d.]+)\\s*(' + unitPattern + ')\\s*(?:等于|是|转成|换成|换算成|换算为|转|到|->|→|to)\\s*(?:多少)?\\s*(' + unitPattern + ')',
248
+ 'i',
249
+ );
250
+ const match = regex.exec(input);
251
+ if (!match) return null;
252
+ const value = Number(match[1]);
253
+ if (!isFinite(value)) return null;
254
+ return { value: value, from: match[2], to: match[3] };
255
+ }
256
+
257
+ /* ─────────────────────────────────────────────────────────────
258
+ * 下一步决策(工具调用 / 最终回答)
259
+ * ─────────────────────────────────────────────────────────────
260
+ */
261
+ function labNextStep(ctx) {
262
+ const input = labCleanText(ctx && ctx.input, LAB_MAX_INPUT_CHARS);
263
+ const step = ctx && typeof ctx.step === 'number' && ctx.step > 0 ? ctx.step : 1;
264
+
265
+ /* 已经执行过工具 → 根据工具结果继续 */
266
+ if (ctx && ctx.lastTool && ctx.lastTool.name) return labLlmContinue(ctx, input);
267
+
268
+ if (!input) return { type: 'final', text: LAB_LLM_EMPTY_TEXT };
269
+ if (labLlmIsHelpQ(input)) return { type: 'final', text: LAB_LLM_HELP_TEXT };
270
+ if (labLlmIsGreetingQ(input)) return { type: 'final', text: LAB_LLM_GREETING_TEXT };
271
+ if (labLlmIsToolsQ(input)) return { type: 'final', text: LAB_LLM_TOOLS_TEXT };
272
+ if (labLlmIsPrincipleQ(input)) return { type: 'final', text: LAB_LLM_PRINCIPLE_TEXT };
273
+ if (labLlmIsForgetQ(input)) return { type: 'final', text: LAB_LLM_FORGET_TEXT };
274
+ if (labLlmIsCompactQ(input)) return { type: 'final', text: labLlmCompactText(ctx) };
275
+
276
+ /* 时间 → clock(可链式记住) */
277
+ if (labLlmIsTimeQ(input)) return { type: 'tool_call', name: 'clock', args: { format: 'full' } };
278
+
279
+ /* 算术 → calculator(可链式记住结果) */
280
+ const expression = labLlmExtractExpression(input);
281
+ if (expression) return { type: 'tool_call', name: 'calculator', args: { expression: expression } };
282
+
283
+ /* 掷骰子 */
284
+ if (labLlmIsDiceQ(input)) return { type: 'tool_call', name: 'dice_roll', args: labLlmDiceArgs(input) };
285
+
286
+ /* 随机选择 */
287
+ if (labLlmIsPickQ(input)) {
288
+ const items = labLlmPickItems(input);
289
+ if (items) return { type: 'tool_call', name: 'random_pick', args: { items: items } };
290
+ }
291
+
292
+ /* 生成密码 */
293
+ if (labLlmIsPasswordQ(input)) return { type: 'tool_call', name: 'password_generate', args: labLlmPasswordArgs(input) };
294
+
295
+ /* 单位换算 */
296
+ const unit = labLlmUnitArgs(input);
297
+ if (unit) return { type: 'tool_call', name: 'unit_convert', args: unit };
298
+
299
+ /* 回忆 → memory_search */
300
+ if (labLlmIsRecallQ(input)) {
301
+ return { type: 'tool_call', name: 'memory_search', args: { query: labLlmRecallQuery(input) } };
302
+ }
303
+
304
+ /* 写入记忆 → memory_write */
305
+ if (labLlmIsWriteQ(input)) {
306
+ const memory = labLlmExtractMemory(input);
307
+ if (!memory || !memory.key || !memory.value) {
308
+ return {
309
+ type: 'final',
310
+ text: '要记什么?可以这样说:「记住 我的项目代号是 Orion」或「记住 截止日期 = 周五」。',
311
+ };
312
+ }
313
+ return { type: 'tool_call', name: 'memory_write', args: { key: memory.key, value: memory.value } };
314
+ }
315
+
316
+ /* 兜底:承认规则模型、复述输入、结合召回记忆与示例 */
317
+ return { type: 'final', text: labLlmFallbackText(ctx, input) };
318
+ }
319
+
320
+ /* 工具结果 → 下一步(最终回答或链式工具调用) */
321
+ function labLlmContinue(ctx, input) {
322
+ const tool = ctx.lastTool || {};
323
+ const args = (tool.args && typeof tool.args === 'object' ? tool.args : {}) || {};
324
+ const result = tool.result && typeof tool.result === 'object' ? tool.result : {};
325
+ const display = tool.display === null || tool.display === undefined ? '' : String(tool.display);
326
+ const reason = display.replace(/^错误:/, '');
327
+ const wantsSave = labLlmWantsSave(input);
328
+
329
+ if (tool.name === 'calculator') {
330
+ if (!tool.ok) {
331
+ return {
332
+ type: 'final',
333
+ text: '这个表达式我算不出来:' + (reason || '计算失败') + '。可以换一个式子,比如 12+30。',
334
+ };
335
+ }
336
+ if (wantsSave) {
337
+ return { type: 'tool_call', name: 'memory_write', args: { key: '计算结果', value: display } };
338
+ }
339
+ const expression = args.expression ? String(args.expression) : '';
340
+ const head = expression ? expression + ' = ' + display + '。' : display + '。';
341
+ return {
342
+ type: 'final',
343
+ text: head + '这是 calculator 工具真实算出来的结果,我只负责决定调用它。',
344
+ };
345
+ }
346
+
347
+ if (tool.name === 'clock') {
348
+ if (!tool.ok) {
349
+ return { type: 'final', text: '读取时间失败了:' + (reason || '未知原因') + '。' };
350
+ }
351
+ if (wantsSave) {
352
+ return { type: 'tool_call', name: 'memory_write', args: { key: '当前时间', value: display } };
353
+ }
354
+ const weekday = result.weekday ? '(' + result.weekday + ')' : '';
355
+ return { type: 'final', text: '现在是 ' + display + weekday + '。这是 clock 工具返回的本机时间。' };
356
+ }
357
+
358
+ const saveKeys = { dice_roll: '掷骰结果', random_pick: '随机选择', password_generate: '生成密码', unit_convert: '换算结果' };
359
+ if (saveKeys[tool.name] && tool.ok && wantsSave) {
360
+ return { type: 'tool_call', name: 'memory_write', args: { key: saveKeys[tool.name], value: display } };
361
+ }
362
+
363
+ if (tool.name === 'dice_roll') {
364
+ if (!tool.ok) return { type: 'final', text: '骰子没掷成:' + (reason || '未知原因') + '。' };
365
+ return { type: 'final', text: '🎲 掷出 ' + display + '。这是 dice_roll 工具真实生成的点数,我只是决定调用它。' };
366
+ }
367
+
368
+ if (tool.name === 'random_pick') {
369
+ if (!tool.ok) return { type: 'final', text: '随机选择失败了:' + (reason || '未知原因') + '。' };
370
+ return { type: 'final', text: '我选:' + display + '。要不要换个选项,或者再抽一次?' };
371
+ }
372
+
373
+ if (tool.name === 'password_generate') {
374
+ if (!tool.ok) return { type: 'final', text: '密码生成失败了:' + (reason || '未知原因') + '。' };
375
+ return { type: 'final', text: '生成好了:' + display + '。这只是本地随机生成,重要账号建议用密码管理器保存。' };
376
+ }
377
+
378
+ if (tool.name === 'unit_convert') {
379
+ if (!tool.ok) return { type: 'final', text: '换算失败了:' + (reason || '未知原因') + '。' };
380
+ return { type: 'final', text: display + '。这是 unit_convert 工具的实际换算结果。' };
381
+ }
382
+
383
+ if (tool.name === 'memory_write') {
384
+ if (!tool.ok) {
385
+ return { type: 'final', text: '保存记忆失败了:' + (reason || '未知原因') + '。' };
386
+ }
387
+ const key = result.key !== undefined ? result.key : args.key || '';
388
+ const value = result.value !== undefined ? result.value : args.value || '';
389
+ return {
390
+ type: 'final',
391
+ text: '已经记住:' + key + ' = ' + value + '。之后你问「' + key + '是什么」,我会用 memory_search 检索出来。',
392
+ };
393
+ }
394
+
395
+ if (tool.name === 'memory_search') {
396
+ if (!tool.ok) {
397
+ return { type: 'final', text: '检索记忆失败了:' + (reason || '未知原因') + '。' };
398
+ }
399
+ const matches = Array.isArray(result.matches) ? result.matches : [];
400
+ if (!matches.length) {
401
+ return {
402
+ type: 'final',
403
+ text: '我这边没有找到相关记忆——你还没告诉过我,或者它已经被清空了。要不要现在「记住 …」一下?',
404
+ };
405
+ }
406
+ const facts = matches
407
+ .slice(0, 3)
408
+ .map(function (item) {
409
+ return item.key + ' = ' + item.value;
410
+ })
411
+ .join(';');
412
+ return {
413
+ type: 'final',
414
+ text: '我记得:' + facts + '。这些是从长期记忆里检索到的,跨会话共享。',
415
+ };
416
+ }
417
+
418
+ return { type: 'final', text: '工具 ' + tool.name + ' 返回:' + (display || '(空结果)') + '。' };
419
+ }
420
+
421
+ /* 压缩意图的回答:尽量给出真实的压缩前后对比 */
422
+ function labLlmCompactText(ctx) {
423
+ const usage = (ctx && ctx.usage) || {};
424
+ const used = typeof usage.used === 'number' ? usage.used : 0;
425
+ const limit = typeof usage.limit === 'number' ? usage.limit : LAB_BUDGET;
426
+ const compression = ctx && ctx.compression;
427
+ if (compression && typeof compression.before === 'number' && typeof compression.after === 'number') {
428
+ return (
429
+ '最近一次压缩把上下文从 ' +
430
+ compression.before +
431
+ ' tok 降到 ' +
432
+ compression.after +
433
+ ' tok。超过预算 90% 会自动压缩:保留最近 ' +
434
+ LAB_RECENT_KEEP +
435
+ ' 条消息,其余折叠成摘要;也可以点「压缩」立即执行。'
436
+ );
437
+ }
438
+ return (
439
+ '当前上下文 ' +
440
+ used +
441
+ '/' +
442
+ limit +
443
+ ' tok,超过 90% 会自动压缩:保留最近 ' +
444
+ LAB_RECENT_KEEP +
445
+ ' 条消息,其余折叠成摘要。点「压缩」可立即执行,右侧检查器能看到前后对比。'
446
+ );
447
+ }
448
+
449
+ /* 兜底回答:承认规则模型,复述输入,结合召回记忆给示例 */
450
+ function labLlmFallbackText(ctx, input) {
451
+ const hits = ctx && Array.isArray(ctx.hits) ? ctx.hits : [];
452
+ let head = '我是规则驱动的模拟模型,没完全理解「' + truncate(input, 18) + '」。';
453
+ if (hits.length > 0) {
454
+ const fact = hits[0];
455
+ head += '我记得' + fact.key + ' = ' + fact.value + '。';
456
+ }
457
+ return head + '可以试试:「帮助」「算一下 (12+30)*3」「掷两个 20 面骰子」「从 火锅、烧烤、日料 里选一个」。';
458
+ }
459
+
460
+ /* ─────────────────────────────────────────────────────────────
461
+ * 流式输出
462
+ * ─────────────────────────────────────────────────────────────
463
+ * 按字符分块;pace 控制块间隔(fast≈3ms / normal≈14ms / slow≈30ms),
464
+ * 总时长压到 ~1.5s 以内。sleep / cancelled / onToken 全部由 engine 注入,
465
+ * 便于测试注入 no-op sleep,也便于 dispose 时立刻停下。
466
+ */
467
+ async function labStreamAnswer(text, opts) {
468
+ const options = opts || {};
469
+ const sleep = typeof options.sleep === 'function' ? options.sleep : null;
470
+ const cancelled = typeof options.cancelled === 'function' ? options.cancelled : function () {
471
+ return false;
472
+ };
473
+ const onToken = typeof options.onToken === 'function' ? options.onToken : function () {};
474
+ const pace = labNormalizePace(options.pace);
475
+ const base = LAB_PACE_DELAYS[pace] || LAB_PACE_DELAYS.normal;
476
+ const chars = Array.from(String(text === null || text === undefined ? '' : text));
477
+ if (!chars.length) return true;
478
+
479
+ /* 最多 60 块,块越大节奏越稳 */
480
+ const chunkSize = Math.max(1, Math.ceil(chars.length / 60));
481
+ const chunks = [];
482
+ for (let i = 0; i < chars.length; i += chunkSize) {
483
+ chunks.push(chars.slice(i, i + chunkSize).join(''));
484
+ }
485
+ /* 保证总等待时间不超过约 1.5s */
486
+ const interval = Math.max(1, Math.min(base, Math.floor(1500 / Math.max(1, chunks.length))));
487
+
488
+ for (let i = 0; i < chunks.length; i += 1) {
489
+ if (cancelled()) return false;
490
+ if (sleep) await sleep(interval);
491
+ if (cancelled()) return false;
492
+ onToken(chunks[i], i === chunks.length - 1);
493
+ }
494
+ return true;
495
+ }