@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.
@@ -0,0 +1,600 @@
1
+ /* ── 20-tools.js · 工具注册表与四个工具实现 ──
2
+ *
3
+ * 工具协议(契约 v1):
4
+ * { name, description, parameters, run(args, ctx) -> { ok, result, display } }
5
+ * - run 可以是 async;
6
+ * - ctx 由 engine 注入:{ now, memory, sessionId, writeMemory, searchMemory };
7
+ * - 工具执行失败不抛异常,返回 { ok:false, result:{error}, display:'错误:…' }。
8
+ *
9
+ * 模拟 LLM(30-llm.js)只负责「决定调哪个工具、传什么参数」,
10
+ * 真正的计算 / 时间 / 记忆读写全部在这里完成,模型不会伪造工具结果。
11
+ */
12
+
13
+ /* ── 安全计算器:词法分析 + 递归下降求值 ──
14
+ * 支持数字、+ - * / % ^、括号与一元正负号;不执行 eval / Function。
15
+ */
16
+
17
+ function labCalcTokenize(expression) {
18
+ const source = String(expression === null || expression === undefined ? '' : expression).replace(/\s+/g, '');
19
+ if (!source) return null;
20
+ const tokens = [];
21
+ let i = 0;
22
+ while (i < source.length) {
23
+ const ch = source.charAt(i);
24
+ if (ch >= '0' && ch <= '9') {
25
+ let j = i;
26
+ let dots = 0;
27
+ while (j < source.length && (source.charAt(j) === '.' || (source.charAt(j) >= '0' && source.charAt(j) <= '9'))) {
28
+ if (source.charAt(j) === '.') dots += 1;
29
+ j += 1;
30
+ }
31
+ if (dots > 1) return null;
32
+ const raw = source.slice(i, j);
33
+ const num = Number(raw);
34
+ if (!isFinite(num)) return null;
35
+ tokens.push({ type: 'num', value: num });
36
+ i = j;
37
+ continue;
38
+ }
39
+ if ('+-*/%^()'.indexOf(ch) >= 0) {
40
+ tokens.push({ type: ch });
41
+ i += 1;
42
+ continue;
43
+ }
44
+ return null; /* 出现不认识的字符 → 语法不合法 */
45
+ }
46
+ return tokens;
47
+ }
48
+
49
+ function labCalcParse(tokens) {
50
+ let pos = 0;
51
+
52
+ function peek() {
53
+ return tokens[pos];
54
+ }
55
+
56
+ function fail(message) {
57
+ throw new Error(message);
58
+ }
59
+
60
+ function parseExpression() {
61
+ let left = parseTerm();
62
+ while (peek() && (peek().type === '+' || peek().type === '-')) {
63
+ const op = tokens[pos].type;
64
+ pos += 1;
65
+ const right = parseTerm();
66
+ left = op === '+' ? left + right : left - right;
67
+ }
68
+ return left;
69
+ }
70
+
71
+ function parseTerm() {
72
+ let left = parseUnary();
73
+ while (peek() && (peek().type === '*' || peek().type === '/' || peek().type === '%')) {
74
+ const op = tokens[pos].type;
75
+ pos += 1;
76
+ const right = parseUnary();
77
+ if (op === '*') {
78
+ left = left * right;
79
+ } else if (op === '/') {
80
+ if (right === 0) fail('除数不能为 0');
81
+ left = left / right;
82
+ } else {
83
+ if (right === 0) fail('取余的除数不能为 0');
84
+ left = left % right;
85
+ }
86
+ }
87
+ return left;
88
+ }
89
+
90
+ function parseUnary() {
91
+ if (peek() && peek().type === '-') {
92
+ pos += 1;
93
+ return -parseUnary();
94
+ }
95
+ if (peek() && peek().type === '+') {
96
+ pos += 1;
97
+ return parseUnary();
98
+ }
99
+ return parsePower();
100
+ }
101
+
102
+ function parsePower() {
103
+ const base = parsePrimary();
104
+ if (peek() && peek().type === '^') {
105
+ pos += 1;
106
+ const exponent = parseUnary(); /* 右结合,且允许 2^-1 */
107
+ return Math.pow(base, exponent);
108
+ }
109
+ return base;
110
+ }
111
+
112
+ function parsePrimary() {
113
+ const token = peek();
114
+ if (!token) fail('表达式不完整');
115
+ if (token.type === 'num') {
116
+ pos += 1;
117
+ return token.value;
118
+ }
119
+ if (token.type === '(') {
120
+ pos += 1;
121
+ const value = parseExpression();
122
+ if (!peek() || peek().type !== ')') fail('括号不匹配');
123
+ pos += 1;
124
+ return value;
125
+ }
126
+ fail('这里需要一个数字或左括号');
127
+ return 0;
128
+ }
129
+
130
+ const result = parseExpression();
131
+ if (pos !== tokens.length) fail('表达式有多余内容');
132
+ return result;
133
+ }
134
+
135
+ /* 计算器工具 */
136
+ const LAB_CALCULATOR = {
137
+ name: 'calculator',
138
+ description: '安全计算四则 / 幂 / 括号表达式,返回数值结果',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ expression: { type: 'string', description: '要计算的表达式,例如 (12+30)*3' },
143
+ },
144
+ required: ['expression'],
145
+ },
146
+ run(args, ctx) {
147
+ try {
148
+ const expression = args && typeof args.expression === 'string' ? args.expression : '';
149
+ if (!expression.trim()) return labToolError('缺少表达式');
150
+ const tokens = labCalcTokenize(expression);
151
+ if (!tokens) return labToolError('只能计算数字与 + - * / % ^ ( )');
152
+ const value = labCalcParse(tokens);
153
+ if (typeof value !== 'number' || !isFinite(value)) return labToolError('计算结果不是有限数字');
154
+ let rounded = Math.round(value * 1e12) / 1e12;
155
+ if (Object.is(rounded, -0)) rounded = 0;
156
+ return {
157
+ ok: true,
158
+ result: { expression: expression.trim(), value: rounded },
159
+ display: String(rounded),
160
+ };
161
+ } catch (err) {
162
+ return labToolError(labErrorMessage(err));
163
+ }
164
+ },
165
+ };
166
+
167
+ /* 时钟工具 */
168
+ const LAB_CLOCK = {
169
+ name: 'clock',
170
+ description: '读取本机当前时间与日期,可指定 format=time/date/full',
171
+ parameters: {
172
+ type: 'object',
173
+ properties: {
174
+ format: { type: 'string', description: 'time | date | full,默认 full' },
175
+ },
176
+ required: [],
177
+ },
178
+ run(args, ctx) {
179
+ try {
180
+ const ts = ctx && typeof ctx.now === 'function' ? ctx.now() : Date.now();
181
+ const d = new Date(ts);
182
+ if (isNaN(d.getTime())) return labToolError('时钟不可用');
183
+ const time = labPad2(d.getHours()) + ':' + labPad2(d.getMinutes()) + ':' + labPad2(d.getSeconds());
184
+ const date = d.getFullYear() + '-' + labPad2(d.getMonth() + 1) + '-' + labPad2(d.getDate());
185
+ const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
186
+ const weekday = weekdays[d.getDay()];
187
+ const format = args && typeof args.format === 'string' ? args.format : 'full';
188
+ let display;
189
+ if (format === 'time') display = time;
190
+ else if (format === 'date') display = date + ' ' + weekday;
191
+ else display = date + ' ' + time;
192
+ return {
193
+ ok: true,
194
+ result: {
195
+ iso: d.toISOString(),
196
+ timestamp: ts,
197
+ date: date,
198
+ time: time,
199
+ weekday: weekday,
200
+ timezoneOffset: d.getTimezoneOffset(),
201
+ },
202
+ display: display,
203
+ };
204
+ } catch (err) {
205
+ return labToolError(labErrorMessage(err));
206
+ }
207
+ },
208
+ };
209
+
210
+ /* 写长期记忆工具 */
211
+ const LAB_MEMORY_WRITE = {
212
+ name: 'memory_write',
213
+ description: '把一条事实写入长期记忆(跨会话共享),相同 key 会覆盖更新',
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ key: { type: 'string', description: '记忆名称,例如 项目代号' },
218
+ value: { type: 'string', description: '记忆内容,例如 Orion' },
219
+ },
220
+ required: ['key', 'value'],
221
+ },
222
+ run(args, ctx) {
223
+ try {
224
+ const key = labCleanText(args && args.key, 40);
225
+ const value = labCleanText(args && args.value, 240);
226
+ if (!key) return labToolError('缺少记忆名称 key');
227
+ if (!value) return labToolError('缺少记忆内容 value');
228
+ if (!ctx || typeof ctx.writeMemory !== 'function') return labToolError('记忆服务不可用');
229
+ const item = ctx.writeMemory({
230
+ key: key,
231
+ value: value,
232
+ source: '记忆工具',
233
+ sessionId: ctx.sessionId,
234
+ });
235
+ if (!item) return labToolError('写入记忆失败');
236
+ return {
237
+ ok: true,
238
+ result: { id: item.id, key: item.key, value: item.value },
239
+ display: item.key + ' = ' + item.value,
240
+ };
241
+ } catch (err) {
242
+ return labToolError(labErrorMessage(err));
243
+ }
244
+ },
245
+ };
246
+
247
+ /* 检索长期记忆工具 */
248
+ const LAB_MEMORY_SEARCH = {
249
+ name: 'memory_search',
250
+ description: '按关键词检索长期记忆,返回最相关的几条事实',
251
+ parameters: {
252
+ type: 'object',
253
+ properties: {
254
+ query: { type: 'string', description: '关键词或问题,例如 项目代号' },
255
+ },
256
+ required: ['query'],
257
+ },
258
+ run(args, ctx) {
259
+ try {
260
+ if (!ctx || typeof ctx.searchMemory !== 'function') return labToolError('记忆服务不可用');
261
+ const query = labCleanText(args && args.query, 120);
262
+ const matches = ctx.searchMemory(query || '') || [];
263
+ const list = matches.map(function (item) {
264
+ return { id: item.id, key: item.key, value: item.value };
265
+ });
266
+ return {
267
+ ok: true,
268
+ result: { query: query, count: list.length, matches: list },
269
+ display: list.length
270
+ ? list
271
+ .map(function (item) {
272
+ return item.key + ' = ' + item.value;
273
+ })
274
+ .join(';')
275
+ : '没有找到相关记忆',
276
+ };
277
+ } catch (err) {
278
+ return labToolError(labErrorMessage(err));
279
+ }
280
+ },
281
+ };
282
+
283
+ /* ─────────────────────────────────────────────────────────────
284
+ * 随机 / 实用工具:让教学 agent 能玩、也能派上用场
285
+ * ───────────────────────────────────────────────────────────── */
286
+
287
+ function labToolRandomInt(max) {
288
+ const limit = Math.max(1, Math.floor(max));
289
+ try {
290
+ const cryptoObj = typeof globalThis !== 'undefined' ? globalThis.crypto : null;
291
+ if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
292
+ const array = new Uint32Array(1);
293
+ cryptoObj.getRandomValues(array);
294
+ return array[0] % limit;
295
+ }
296
+ } catch (error) {
297
+ /* 浏览器不支持 crypto 时退回 Math.random */
298
+ }
299
+ return Math.floor(Math.random() * limit);
300
+ }
301
+
302
+ function labClampInt(raw, min, max, fallback) {
303
+ const number = Number(raw);
304
+ if (!isFinite(number)) return fallback;
305
+ return Math.max(min, Math.min(max, Math.floor(number)));
306
+ }
307
+
308
+ /* 掷骰子 */
309
+ const LAB_DICE = {
310
+ name: 'dice_roll',
311
+ description: '掷骰子,支持指定面数与个数,返回每次点数与合计。用户说「掷骰子/roll/随机点数/抽个签」时使用。',
312
+ parameters: {
313
+ type: 'object',
314
+ properties: {
315
+ sides: { type: 'integer', description: '骰子面数,默认 6,范围 2..1000' },
316
+ count: { type: 'integer', description: '掷几个,默认 1,范围 1..20' },
317
+ },
318
+ required: [],
319
+ },
320
+ run(args) {
321
+ try {
322
+ const sides = labClampInt(args && args.sides, 2, 1000, 6);
323
+ const count = labClampInt(args && args.count, 1, 20, 1);
324
+ const rolls = [];
325
+ let total = 0;
326
+ for (let i = 0; i < count; i += 1) {
327
+ const value = labToolRandomInt(sides) + 1;
328
+ rolls.push(value);
329
+ total += value;
330
+ }
331
+ return {
332
+ ok: true,
333
+ result: { sides: sides, count: count, rolls: rolls, total: total },
334
+ display: rolls.join(' + ') + (count > 1 ? ' = ' + total : '') + '(D' + sides + ')',
335
+ };
336
+ } catch (error) {
337
+ return labToolError(labErrorMessage(error));
338
+ }
339
+ },
340
+ };
341
+
342
+ /* 从候选项里随机选一个 */
343
+ const LAB_RANDOM_PICK = {
344
+ name: 'random_pick',
345
+ description: '从两个或多个候选项里随机挑一个,适合「帮我选/二选一/抽一个」。不要用它替代用户做重要决策,只做随机选择。',
346
+ parameters: {
347
+ type: 'object',
348
+ properties: {
349
+ items: {
350
+ type: 'array',
351
+ description: '候选项列表,至少 2 个、最多 20 个',
352
+ items: { type: 'string' },
353
+ },
354
+ },
355
+ required: ['items'],
356
+ },
357
+ run(args) {
358
+ try {
359
+ const raw = args && Array.isArray(args.items) ? args.items : [];
360
+ const items = [];
361
+ for (let i = 0; i < raw.length && items.length < 20; i += 1) {
362
+ const item = labCleanText(raw[i], 60);
363
+ if (item) items.push(item);
364
+ }
365
+ if (items.length < 2) return labToolError('至少需要两个候选项,例如:火锅、烧烤、日料');
366
+ const index = labToolRandomInt(items.length);
367
+ return {
368
+ ok: true,
369
+ result: { items: items, index: index, choice: items[index] },
370
+ display: items[index] + '(从 ' + items.length + ' 个选项中随机选出)',
371
+ };
372
+ } catch (error) {
373
+ return labToolError(labErrorMessage(error));
374
+ }
375
+ },
376
+ };
377
+
378
+ /* 生成随机密码 */
379
+ const LAB_PASSWORD = {
380
+ name: 'password_generate',
381
+ description: '生成随机密码,可指定长度以及是否包含数字/符号。用户说「生成密码/随机密码」时使用。',
382
+ parameters: {
383
+ type: 'object',
384
+ properties: {
385
+ length: { type: 'integer', description: '密码长度,默认 16,范围 6..64' },
386
+ numbers: { type: 'boolean', description: '是否包含数字,默认 true' },
387
+ symbols: { type: 'boolean', description: '是否包含特殊符号,默认 true' },
388
+ },
389
+ required: [],
390
+ },
391
+ run(args) {
392
+ try {
393
+ const length = labClampInt(args && args.length, 6, 64, 16);
394
+ const withNumbers = !(args && args.numbers === false);
395
+ const withSymbols = !(args && args.symbols === false);
396
+ const lower = 'abcdefghijkmnopqrstuvwxyz';
397
+ const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
398
+ const digits = '23456789';
399
+ const symbols = '!@#$%^&*-_=+?';
400
+ let alphabet = lower + upper;
401
+ const required = [lower[labToolRandomInt(lower.length)], upper[labToolRandomInt(upper.length)]];
402
+ if (withNumbers) {
403
+ alphabet += digits;
404
+ required.push(digits[labToolRandomInt(digits.length)]);
405
+ }
406
+ if (withSymbols) {
407
+ alphabet += symbols;
408
+ required.push(symbols[labToolRandomInt(symbols.length)]);
409
+ }
410
+ const chars = required.slice(0, length);
411
+ while (chars.length < length) chars.push(alphabet[labToolRandomInt(alphabet.length)]);
412
+ /* 洗牌,避免固定位置总是某类字符 */
413
+ for (let i = chars.length - 1; i > 0; i -= 1) {
414
+ const j = labToolRandomInt(i + 1);
415
+ const tmp = chars[i];
416
+ chars[i] = chars[j];
417
+ chars[j] = tmp;
418
+ }
419
+ const password = chars.join('');
420
+ return {
421
+ ok: true,
422
+ result: { password: password, length: length, numbers: withNumbers, symbols: withSymbols },
423
+ display: password + '(' + length + ' 位)',
424
+ };
425
+ } catch (error) {
426
+ return labToolError(labErrorMessage(error));
427
+ }
428
+ },
429
+ };
430
+
431
+ /* 单位换算 */
432
+ const LAB_UNIT_ALIASES = {
433
+ m: 'm', 米: 'm', meter: 'm', meters: 'm', metre: 'm', metres: 'm',
434
+ km: 'km', 公里: 'km', 千米: 'km', kilometer: 'km', kilometers: 'km', kilometre: 'km', kilometres: 'km',
435
+ cm: 'cm', 厘米: 'cm', centimeter: 'cm', centimeters: 'cm',
436
+ mm: 'mm', 毫米: 'mm', millimeter: 'mm', millimeters: 'mm',
437
+ mi: 'mi', 英里: 'mi', mile: 'mi', miles: 'mi',
438
+ yd: 'yd', 码: 'yd', yard: 'yd', yards: 'yd',
439
+ ft: 'ft', 英尺: 'ft', foot: 'ft', feet: 'ft',
440
+ in: 'in', 英寸: 'in', inch: 'in', inches: 'in',
441
+ kg: 'kg', 公斤: 'kg', 千克: 'kg', kilogram: 'kg', kilograms: 'kg',
442
+ g: 'g', 克: 'g', gram: 'g', grams: 'g',
443
+ mg: 'mg', 毫克: 'mg', milligram: 'mg', milligrams: 'mg',
444
+ t: 't', 吨: 't', ton: 't', tons: 't',
445
+ jin: 'jin', 斤: 'jin',
446
+ lb: 'lb', 磅: 'lb', pound: 'lb', pounds: 'lb',
447
+ oz: 'oz', 盎司: 'oz', ounce: 'oz', ounces: 'oz',
448
+ l: 'l', 升: 'l', liter: 'l', liters: 'l', litre: 'l', litres: 'l',
449
+ ml: 'ml', 毫升: 'ml', milliliter: 'ml', milliliters: 'ml',
450
+ gal: 'gal', 加仑: 'gal', gallon: 'gal', gallons: 'gal',
451
+ mps: 'mps', 'm/s': 'mps', 米每秒: 'mps',
452
+ kmh: 'kmh', 'km/h': 'kmh', 公里每小时: 'kmh', 千米每小时: 'kmh',
453
+ mph: 'mph', 'mi/h': 'mph', 英里每小时: 'mph',
454
+ b: 'b', 字节: 'b', byte: 'b', bytes: 'b',
455
+ kb: 'kb', 千字节: 'kb', kilobyte: 'kb', kilobytes: 'kb',
456
+ mb: 'mb', 兆字节: 'mb', megabyte: 'mb', megabytes: 'mb',
457
+ gb: 'gb', 吉字节: 'gb', gigabyte: 'gb', gigabytes: 'gb',
458
+ c: 'c', '°c': 'c', 摄氏度: 'c', 摄氏: 'c', celsius: 'c', centigrade: 'c', '℃': 'c',
459
+ f: 'f', '°f': 'f', 华氏度: 'f', 华氏: 'f', fahrenheit: 'f', '℉': 'f',
460
+ k: 'k', 开尔文: 'k', kelvin: 'k',
461
+ };
462
+
463
+ const LAB_UNIT_TABLE = {
464
+ m: { kind: 'length', factor: 1, label: '米' },
465
+ km: { kind: 'length', factor: 1000, label: '公里' },
466
+ cm: { kind: 'length', factor: 0.01, label: '厘米' },
467
+ mm: { kind: 'length', factor: 0.001, label: '毫米' },
468
+ mi: { kind: 'length', factor: 1609.344, label: '英里' },
469
+ yd: { kind: 'length', factor: 0.9144, label: '码' },
470
+ ft: { kind: 'length', factor: 0.3048, label: '英尺' },
471
+ in: { kind: 'length', factor: 0.0254, label: '英寸' },
472
+ kg: { kind: 'mass', factor: 1, label: '公斤' },
473
+ g: { kind: 'mass', factor: 0.001, label: '克' },
474
+ mg: { kind: 'mass', factor: 0.000001, label: '毫克' },
475
+ t: { kind: 'mass', factor: 1000, label: '吨' },
476
+ jin: { kind: 'mass', factor: 0.5, label: '斤' },
477
+ lb: { kind: 'mass', factor: 0.45359237, label: '磅' },
478
+ oz: { kind: 'mass', factor: 0.028349523125, label: '盎司' },
479
+ l: { kind: 'volume', factor: 1, label: '升' },
480
+ ml: { kind: 'volume', factor: 0.001, label: '毫升' },
481
+ gal: { kind: 'volume', factor: 3.785411784, label: '加仑' },
482
+ mps: { kind: 'speed', factor: 1, label: '米/秒' },
483
+ kmh: { kind: 'speed', factor: 0.2777777778, label: '公里/小时' },
484
+ mph: { kind: 'speed', factor: 0.44704, label: '英里/小时' },
485
+ b: { kind: 'data', factor: 1, label: '字节' },
486
+ kb: { kind: 'data', factor: 1024, label: 'KB' },
487
+ mb: { kind: 'data', factor: 1048576, label: 'MB' },
488
+ gb: { kind: 'data', factor: 1073741824, label: 'GB' },
489
+ };
490
+
491
+ function labEscapeRegExp(text) {
492
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
493
+ }
494
+
495
+ /* 供规则模型识别单位词(按长度倒序,长词优先) */
496
+ const LAB_UNIT_WORD_PATTERN = Object.keys(LAB_UNIT_ALIASES)
497
+ .sort(function (a, b) {
498
+ return b.length - a.length;
499
+ })
500
+ .map(labEscapeRegExp)
501
+ .join('|');
502
+
503
+ function labNormalizeUnit(raw) {
504
+ const key = String(raw === null || raw === undefined ? '' : raw).trim().toLowerCase();
505
+ return LAB_UNIT_ALIASES[key] || null;
506
+ }
507
+
508
+ function labRoundNumber(value) {
509
+ if (!isFinite(value)) return value;
510
+ return Math.round(value * 1e6) / 1e6;
511
+ }
512
+
513
+ function labTemperatureConvert(value, from, to) {
514
+ let celsius;
515
+ if (from === 'c') celsius = value;
516
+ else if (from === 'f') celsius = (value - 32) * 5 / 9;
517
+ else celsius = value - 273.15;
518
+ if (to === 'c') return celsius;
519
+ if (to === 'f') return celsius * 9 / 5 + 32;
520
+ return celsius + 273.15;
521
+ }
522
+
523
+ const LAB_UNIT_CONVERT = {
524
+ name: 'unit_convert',
525
+ description: '换算单位:长度(米/公里/英里/英尺/英寸)、重量(公斤/斤/磅)、温度(摄氏/华氏/开尔文)、体积、速度、数据大小。用户说「X 等于多少 Y」「换算」时使用。',
526
+ parameters: {
527
+ type: 'object',
528
+ properties: {
529
+ value: { type: 'number', description: '要换算的数值' },
530
+ from: { type: 'string', description: '原单位,例如 公里 / km / 摄氏度 / kg' },
531
+ to: { type: 'string', description: '目标单位,例如 英里 / mi / 华氏度 / 磅' },
532
+ },
533
+ required: ['value', 'from', 'to'],
534
+ },
535
+ run(args) {
536
+ try {
537
+ const value = Number(args && args.value);
538
+ if (!isFinite(value)) return labToolError('缺少有效的数值');
539
+ const from = labNormalizeUnit(args && args.from);
540
+ const to = labNormalizeUnit(args && args.to);
541
+ if (!from) return labToolError('不认识原单位:' + String(args && args.from));
542
+ if (!to) return labToolError('不认识目标单位:' + String(args && args.to));
543
+ const isTemp = from === 'c' || from === 'f' || from === 'k' || to === 'c' || to === 'f' || to === 'k';
544
+ let converted;
545
+ let fromLabel = from.toUpperCase();
546
+ let toLabel = to.toUpperCase();
547
+ if (isTemp) {
548
+ if (!((from === 'c' || from === 'f' || from === 'k') && (to === 'c' || to === 'f' || to === 'k'))) {
549
+ return labToolError('温度只能和温度互相换算');
550
+ }
551
+ converted = labTemperatureConvert(value, from, to);
552
+ fromLabel = { c: '°C', f: '°F', k: 'K' }[from];
553
+ toLabel = { c: '°C', f: '°F', k: 'K' }[to];
554
+ } else {
555
+ const source = LAB_UNIT_TABLE[from];
556
+ const target = LAB_UNIT_TABLE[to];
557
+ if (!source || !target) return labToolError('这个单位暂不支持换算');
558
+ if (source.kind !== target.kind) return labToolError('不能把「' + source.label + '」和「' + target.label + '」互相换算');
559
+ converted = value * source.factor / target.factor;
560
+ fromLabel = source.label;
561
+ toLabel = target.label;
562
+ }
563
+ const rounded = labRoundNumber(converted);
564
+ return {
565
+ ok: true,
566
+ result: { value: value, from: from, to: to, converted: rounded, kind: isTemp ? 'temperature' : LAB_UNIT_TABLE[from].kind },
567
+ display: value + ' ' + fromLabel + ' = ' + rounded + ' ' + toLabel,
568
+ };
569
+ } catch (error) {
570
+ return labToolError(labErrorMessage(error));
571
+ }
572
+ },
573
+ };
574
+
575
+ /* ── 工具注册表(engine 按 name 查找;UI 可据此展示) ── */
576
+ const LAB_TOOLS = [
577
+ LAB_CALCULATOR,
578
+ LAB_CLOCK,
579
+ LAB_DICE,
580
+ LAB_RANDOM_PICK,
581
+ LAB_PASSWORD,
582
+ LAB_UNIT_CONVERT,
583
+ LAB_MEMORY_WRITE,
584
+ LAB_MEMORY_SEARCH,
585
+ ];
586
+
587
+ /* 发给模型的工具 schema(去掉 run,model stage detail 里会用到) */
588
+ const LAB_TOOL_SCHEMAS = LAB_TOOLS.map(function (tool) {
589
+ return {
590
+ name: tool.name,
591
+ description: tool.description,
592
+ parameters: tool.parameters,
593
+ };
594
+ });
595
+
596
+ /* 统一构造工具失败结果 */
597
+ function labToolError(message) {
598
+ const text = labCleanText(message, 120) || '工具调用失败';
599
+ return { ok: false, result: { error: text }, display: '错误:' + text };
600
+ }