llm-api-gateway-cli 1.0.5 → 1.0.6
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/README.en.md +116 -2
- package/README.md +101 -2
- package/cli-agent.js +186 -49
- package/cli-anthropic.js +57 -13
- package/cli-claude-code.js +59 -12
- package/cli-openai.js +48 -11
- package/lib/commands.js +397 -70
- package/lib/config.js +22 -2
- package/lib/configcmd.js +146 -45
- package/lib/hub.js +435 -43
- package/lib/i18n.js +80 -0
- package/lib/launcher.js +78 -24
- package/lib/mcp.js +16 -6
- package/lib/mcpadmin.js +331 -0
- package/lib/setup.js +101 -36
- package/package.json +1 -1
- package/public/manual.html +107 -3
- package/public/mcp.css +155 -0
- package/public/mcp.html +135 -0
- package/public/mcp.js +327 -0
- package/public/sessions.css +198 -0
- package/public/sessions.html +89 -0
- package/public/sessions.js +281 -0
- package/public/shell.html +8 -0
- package/public/shell.js +20 -4
- package/public/task.css +5 -0
- package/public/task.html +14 -1
package/lib/commands.js
CHANGED
|
@@ -22,8 +22,17 @@
|
|
|
22
22
|
|
|
23
23
|
import { formatCost } from './pricing.js';
|
|
24
24
|
import { configCommand, splitConfigArg } from './configcmd.js';
|
|
25
|
-
// /mcp
|
|
25
|
+
// /mcp:清单来自网关 GET /v1/mcp/tools;绑定与自助登记走管理面(同一条命令,见 mcpCommand)
|
|
26
26
|
import { refreshMcpTools, mcpStatusText, mcpToolList } from './mcp.js';
|
|
27
|
+
import {
|
|
28
|
+
getMcpBinding, patchMcpBinding, createMcpServer, deleteMcpServer,
|
|
29
|
+
parseHeaderArgs, urlHasCredential, maskUrl, serverLine, selfServerText, MCP_SUBCOMMANDS,
|
|
30
|
+
configureMcpAdmin,
|
|
31
|
+
} from './mcpadmin.js';
|
|
32
|
+
// CLI 侧语言:表里每条命令带一份 `en` 元数据,`/help` 的正文按当前语言取。
|
|
33
|
+
// 注意 commandRows() / helpText() 的**默认语言是 zh**(不是 getLang())—— 网关进程用它生成
|
|
34
|
+
// `/api/commands` 喂给中文手册页,绝不能被 `node server.js --lang en` 带跑偏。
|
|
35
|
+
import { pick, getLang } from './i18n.js';
|
|
27
36
|
|
|
28
37
|
/** 压缩上下文时保留最近几条消息(不含 system) */
|
|
29
38
|
const COMPACT_KEEP = 6;
|
|
@@ -46,7 +55,7 @@ export function tokenize(s) {
|
|
|
46
55
|
if (i >= raw.length) break;
|
|
47
56
|
if (raw[i] === '"') {
|
|
48
57
|
const end = raw.indexOf('"', i + 1);
|
|
49
|
-
if (end < 0) throw new Error('引号没有闭合(用 " 包住带空格的参数)');
|
|
58
|
+
if (end < 0) throw new Error(pick('引号没有闭合(用 " 包住带空格的参数)', 'unclosed quote (wrap arguments containing spaces in ")'));
|
|
50
59
|
out.push(raw.slice(i + 1, end));
|
|
51
60
|
i = end + 1;
|
|
52
61
|
} else {
|
|
@@ -209,7 +218,8 @@ export const COMMANDS = {
|
|
|
209
218
|
surfaces: ['cli'],
|
|
210
219
|
usage: '/help [命令名]',
|
|
211
220
|
desc: '显示命令列表,或查看某条命令的用法',
|
|
212
|
-
|
|
221
|
+
en: { args: '[command]', group: 'Help', usage: '/help [command]', desc: 'List commands, or show the usage of one command' },
|
|
222
|
+
run: (ctx, arg) => ctx.say(helpText(arg, getLang())),
|
|
213
223
|
},
|
|
214
224
|
'/model': {
|
|
215
225
|
name: 'model',
|
|
@@ -221,11 +231,14 @@ export const COMMANDS = {
|
|
|
221
231
|
surfaces: ['cli'],
|
|
222
232
|
usage: '/model [模型名]',
|
|
223
233
|
desc: '查看或切换当前模型(保留上下文)',
|
|
234
|
+
en: { args: '[model]', group: 'Model & request', usage: '/model [model]', desc: 'Show or switch the current model (context is kept)' },
|
|
224
235
|
run: (ctx, arg) => {
|
|
225
|
-
if (!arg)
|
|
236
|
+
if (!arg) {
|
|
237
|
+
return ctx.say(pick(`当前模型:${ctx.session.model}(用法:/model <模型名>)`, `Current model: ${ctx.session.model} (usage: /model <model>)`));
|
|
238
|
+
}
|
|
226
239
|
ctx.setModel(arg);
|
|
227
240
|
ctx.persist();
|
|
228
|
-
ctx.say(`模型已切换为 ${arg}
|
|
241
|
+
ctx.say(pick(`模型已切换为 ${arg}(上下文保留,下一步生效)。`, `Model switched to ${arg} (context kept, effective from the next turn).`));
|
|
229
242
|
},
|
|
230
243
|
},
|
|
231
244
|
'/config': {
|
|
@@ -238,12 +251,13 @@ export const COMMANDS = {
|
|
|
238
251
|
surfaces: ['cli'],
|
|
239
252
|
usage: '/config [list|get|set|unset …]',
|
|
240
253
|
desc: '查看或修改持久化配置(与 gateway-agent config 同一套)',
|
|
254
|
+
en: { group: 'Config', desc: 'View or change persisted settings (same as gateway-agent config)' },
|
|
241
255
|
run: (ctx, arg) => {
|
|
242
256
|
const r = configCommand(splitConfigArg(arg), ctx.configOpts ? ctx.configOpts() : {});
|
|
243
257
|
// 成功走 say,失败也走 say —— REPL 里没有独立的 stderr 通道,
|
|
244
258
|
// 而且这条命令的输出本来就是给人看的
|
|
245
259
|
const text = (r.code === 0 ? r.out : r.err).trimEnd();
|
|
246
|
-
ctx.say(text || '(没有输出)');
|
|
260
|
+
ctx.say(text || pick('(没有输出)', '(no output)'));
|
|
247
261
|
},
|
|
248
262
|
},
|
|
249
263
|
'/cost': {
|
|
@@ -256,53 +270,32 @@ export const COMMANDS = {
|
|
|
256
270
|
surfaces: ['cli'],
|
|
257
271
|
usage: '/cost',
|
|
258
272
|
desc: '查看本会话累计 token 与费用粗估(口径:内置单价表)',
|
|
273
|
+
en: { group: 'Usage & cost', desc: 'Show cumulative tokens and a rough cost estimate for this session (built-in price table)' },
|
|
259
274
|
run: (ctx) => {
|
|
260
275
|
ctx.say(formatCost(ctx.session.usage, ctx.session.model));
|
|
261
276
|
// 自报口径:网关是记账方,内置表只是本机估算。两端口径必须能被一眼区分,
|
|
262
277
|
// 否则同一台机器上 /cost 与网关账单会"各自说各自的数"(对齐方案 §3.2)。
|
|
263
|
-
ctx.say('口径:内置单价表估算,非网关计费口径(网关 /admin/prices 才是记账口径)。');
|
|
278
|
+
ctx.say(pick('口径:内置单价表估算,非网关计费口径(网关 /admin/prices 才是记账口径)。', 'Note: estimated from the built-in price table, not the gateway billing figure (the gateway /admin/prices is authoritative).'));
|
|
264
279
|
},
|
|
265
280
|
},
|
|
266
281
|
'/mcp': {
|
|
267
282
|
name: 'mcp',
|
|
268
283
|
aliases: [],
|
|
269
|
-
args: '[refresh]',
|
|
284
|
+
args: '[refresh|bind|mode|add|rm]',
|
|
270
285
|
needsArgs: false,
|
|
271
286
|
group: '工具与 MCP',
|
|
272
287
|
capability: 'api', // 清单来自网关 GET /v1/mcp/tools
|
|
273
288
|
surfaces: ['cli'],
|
|
274
|
-
usage: '/mcp [refresh]',
|
|
275
|
-
desc: '
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const info = await refreshMcpTools({ force, baseUrl: cfg.baseUrl, key: cfg.key });
|
|
282
|
-
ctx.say(mcpStatusText());
|
|
283
|
-
for (const t of mcpToolList()) {
|
|
284
|
-
const what = [t.server, t.tool].filter(Boolean).join('/');
|
|
285
|
-
const brief = String(t.description || '').split('\n')[0].slice(0, 60);
|
|
286
|
-
ctx.say(` ${t.name}${what ? ` [${what}]` : ''}${brief ? ` ${brief}` : ''}`);
|
|
287
|
-
}
|
|
288
|
-
if (info?.error) {
|
|
289
|
-
// 把「发没发请求、为什么没有」直说出来:否则「CLI 没拉清单」(GATEWAY_MCP=off、
|
|
290
|
-
// 密钥配置不全)与「拉了但网关没给」(404/断网)在用户眼里长得一模一样,只能去翻网关日志。
|
|
291
|
-
// attemptedAt 是 lib/mcp.js 在真正发请求时记下的,所以这句是事实而不是推测。
|
|
292
|
-
ctx.say(info.attemptedAt
|
|
293
|
-
? ` · 已请求 GET ${cfg.baseUrl || '?'}/v1/mcp/tools(${force ? '强制刷新' : '按需'}):${info.error}`
|
|
294
|
-
: ` · 未向网关发出请求:${info.error}`);
|
|
295
|
-
// GATEWAY_MCP=off 是用户自己关的,再让他去查网关就是误导
|
|
296
|
-
if (!/GATEWAY_MCP=off/.test(info.error)) {
|
|
297
|
-
ctx.say(' · 常见原因:网关未开启 MCP / 该密钥未绑定任何工具 / 网关版本不带这个接口');
|
|
298
|
-
ctx.say(' · 看细节:GATEWAY_MCP_DEBUG=1 重跑,每次拉取与调用都会打到 stderr');
|
|
299
|
-
}
|
|
300
|
-
} else if (force) {
|
|
301
|
-
ctx.say(' · 已强制重拉(后续对话立即用这份新清单)');
|
|
302
|
-
} else {
|
|
303
|
-
ctx.say(' · 清单来自网关 GET /v1/mcp/tools(60 秒 TTL);/mcp refresh 可立即重拉');
|
|
304
|
-
}
|
|
289
|
+
usage: '/mcp [refresh|bind|mode|add|rm]',
|
|
290
|
+
desc: 'MCP:看清单与绑定;bind 改绑、mode 选模式、add 登记自己的服务器、rm 删掉',
|
|
291
|
+
en: {
|
|
292
|
+
args: '[refresh|bind|mode|add|rm]',
|
|
293
|
+
group: 'Tools & MCP',
|
|
294
|
+
usage: '/mcp [refresh|bind|mode|add|rm]',
|
|
295
|
+
desc: 'MCP: list tools and bindings; bind/mode change them, add registers your own server, rm removes one',
|
|
305
296
|
},
|
|
297
|
+
// 异步:除了总览,其余子命令都要真的出网(REPL 循环对 run 的返回值已经 await)
|
|
298
|
+
run: (ctx, arg) => mcpCommand(ctx, arg),
|
|
306
299
|
},
|
|
307
300
|
'/reset': {
|
|
308
301
|
name: 'reset',
|
|
@@ -314,10 +307,11 @@ export const COMMANDS = {
|
|
|
314
307
|
surfaces: ['cli'],
|
|
315
308
|
usage: '/reset',
|
|
316
309
|
desc: '重开会话(清空上下文,工作目录不变)',
|
|
310
|
+
en: { group: 'Session & context', desc: 'Restart the session (context cleared, working directory unchanged)' },
|
|
317
311
|
run: (ctx) => {
|
|
318
312
|
ctx.setSession(ctx.newSession());
|
|
319
313
|
ctx.persist();
|
|
320
|
-
ctx.say('已重开会话(上下文清空,工作目录不变)。');
|
|
314
|
+
ctx.say(pick('已重开会话(上下文清空,工作目录不变)。', 'Session restarted (context cleared, working directory unchanged).'));
|
|
321
315
|
},
|
|
322
316
|
},
|
|
323
317
|
'/clear': {
|
|
@@ -330,6 +324,7 @@ export const COMMANDS = {
|
|
|
330
324
|
surfaces: ['cli'],
|
|
331
325
|
usage: '/clear',
|
|
332
326
|
desc: '/reset 的别名(清空上下文重开)',
|
|
327
|
+
en: { group: 'Session & context', desc: 'alias of /reset (clear the context and start over)' },
|
|
333
328
|
run: (ctx, arg, argv) => COMMANDS['/reset'].run(ctx, arg, argv),
|
|
334
329
|
},
|
|
335
330
|
'/resume': {
|
|
@@ -342,18 +337,22 @@ export const COMMANDS = {
|
|
|
342
337
|
surfaces: ['cli'],
|
|
343
338
|
usage: '/resume <会话id>',
|
|
344
339
|
desc: '切到另一个已保存的会话(不带参数列出最近会话)',
|
|
340
|
+
en: { args: '<session-id>', group: 'Session & context', usage: '/resume <session-id>', desc: 'Switch to another saved session (without arguments, list recent sessions)' },
|
|
345
341
|
run: (ctx, arg) => {
|
|
346
342
|
if (!arg) {
|
|
347
343
|
const list = ctx.listSessions ? ctx.listSessions() : [];
|
|
348
|
-
if (!list.length) return ctx.say('没有已保存的会话。(用 /resume <id> 恢复;会话目录见启动横幅)');
|
|
349
|
-
const lines = list.slice(0, 10).map((s) =>
|
|
350
|
-
|
|
344
|
+
if (!list.length) return ctx.say(pick('没有已保存的会话。(用 /resume <id> 恢复;会话目录见启动横幅)', 'No saved sessions. (Use /resume <id> to resume; the session directory is shown in the startup banner.)'));
|
|
345
|
+
const lines = list.slice(0, 10).map((s) => pick(
|
|
346
|
+
` ${s.id} ${s.model || '?'} ${s.messages || 0} 条 ${new Date(s.touchedAt).toLocaleString()}`,
|
|
347
|
+
` ${s.id} ${s.model || '?'} ${s.messages || 0} msgs ${new Date(s.touchedAt).toLocaleString()}`,
|
|
348
|
+
));
|
|
349
|
+
return ctx.say(pick(`最近的会话:\n${lines.join('\n')}\n用 /resume <id> 恢复其中一个。`, `Recent sessions:\n${lines.join('\n')}\nResume one with /resume <id>.`));
|
|
351
350
|
}
|
|
352
351
|
const rec = ctx.loadSession ? ctx.loadSession(arg) : null;
|
|
353
|
-
if (!rec) return ctx.say(`找不到会话 ${arg}(id 是 UUID;可用 /resume
|
|
352
|
+
if (!rec) return ctx.say(pick(`找不到会话 ${arg}(id 是 UUID;可用 /resume 不带参数列出)。`, `Session ${arg} not found (the id is a UUID; run /resume without arguments to list them).`));
|
|
354
353
|
ctx.setSession(rec);
|
|
355
354
|
ctx.persist();
|
|
356
|
-
ctx.say(`已切换到会话 ${rec.id}(${rec.model || '?'},工作目录 ${rec.workingDir || '?'}
|
|
355
|
+
ctx.say(pick(`已切换到会话 ${rec.id}(${rec.model || '?'},工作目录 ${rec.workingDir || '?'})。`, `Switched to session ${rec.id} (${rec.model || '?'}, working directory ${rec.workingDir || '?'}).`));
|
|
357
356
|
},
|
|
358
357
|
},
|
|
359
358
|
'/compact': {
|
|
@@ -366,18 +365,20 @@ export const COMMANDS = {
|
|
|
366
365
|
surfaces: ['cli'],
|
|
367
366
|
usage: '/compact',
|
|
368
367
|
desc: `压缩上下文:只保留最近 ${COMPACT_KEEP} 条消息(本地操作,不调用模型)`,
|
|
368
|
+
en: { group: 'Session & context', desc: `Compact the context: keep only the most recent ${COMPACT_KEEP} messages (local, no model call)` },
|
|
369
369
|
run: (ctx) => {
|
|
370
370
|
const msgs = ctx.session.messages;
|
|
371
|
-
if (!Array.isArray(msgs) || msgs.length <= COMPACT_KEEP + 1) return ctx.say('上下文不长,无需压缩。');
|
|
371
|
+
if (!Array.isArray(msgs) || msgs.length <= COMPACT_KEEP + 1) return ctx.say(pick('上下文不长,无需压缩。', 'The context is short; nothing to compact.'));
|
|
372
372
|
const head = msgs[0];
|
|
373
373
|
const dropped = msgs.length - 1 - COMPACT_KEEP;
|
|
374
374
|
const note = {
|
|
375
375
|
role: 'user',
|
|
376
|
+
// 这条是**发进对话**的说明(模型侧文本),与系统提示词同口径:本轮不随界面语言翻译
|
|
376
377
|
content: `(本地已压缩上下文:较早的 ${dropped} 条消息被丢弃。如仍需要那些信息,请重新说明。)`,
|
|
377
378
|
};
|
|
378
379
|
ctx.session.messages = [head, note, ...msgs.slice(-COMPACT_KEEP)];
|
|
379
380
|
ctx.persist();
|
|
380
|
-
ctx.say(`已压缩上下文:丢弃 ${dropped} 条较早消息,保留最近 ${COMPACT_KEEP}
|
|
381
|
+
ctx.say(pick(`已压缩上下文:丢弃 ${dropped} 条较早消息,保留最近 ${COMPACT_KEEP} 条。`, `Context compacted: dropped ${dropped} earlier messages, kept the most recent ${COMPACT_KEEP}.`));
|
|
381
382
|
},
|
|
382
383
|
},
|
|
383
384
|
'/exit': {
|
|
@@ -391,24 +392,318 @@ export const COMMANDS = {
|
|
|
391
392
|
bare: true, // 裸词 exit / quit 也认(原来硬编码在 cli-agent 的循环里)
|
|
392
393
|
usage: '/exit',
|
|
393
394
|
desc: '退出(裸词 exit / quit 同样有效)',
|
|
395
|
+
en: { group: 'Process', desc: 'Quit (the bare words exit / quit work too)' },
|
|
394
396
|
run: () => ({ exit: true }),
|
|
395
397
|
},
|
|
396
398
|
};
|
|
397
399
|
|
|
400
|
+
/* ============================ /mcp(清单 + 绑定 + 自助登记) ============================ */
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `/mcp` 的分派。
|
|
404
|
+
*
|
|
405
|
+
* 分成两类,纪律完全不同:
|
|
406
|
+
* · **清单**(无参数 / `refresh`)走 `lib/mcp.js`:best-effort、失败静默降级,
|
|
407
|
+
* 因为它也在对话开场路径上,绝不能把一次正常对话搞挂。
|
|
408
|
+
* · **管理**(`bind` / `mode` / `add` / `rm`)走 `lib/mcpadmin.js`:**显式失败**。
|
|
409
|
+
* 用户敲了「加」,就必须看到成没成;在这里静默是缺陷不是稳健。
|
|
410
|
+
*
|
|
411
|
+
* 「能不能自助登记」的判据一律来自网关(`self_server.allowed`),CLI 不猜默认值——
|
|
412
|
+
* 猜错的表现是「显示了入口、一点就 403」或者「明明开了却不给用」。
|
|
413
|
+
*/
|
|
414
|
+
async function mcpCommand(ctx, arg) {
|
|
415
|
+
const cfg = ctx.cfg || {};
|
|
416
|
+
// 管理面(lib/mcpadmin.js)用的是**自己的**连接状态,正常由 refreshMcpTools → configureMcp 顺带同步。
|
|
417
|
+
// 但命令层手里直接就有 cfg,这里显式同步一次,免得两条路各说各话:
|
|
418
|
+
// · 宿主没先刷过清单(或刷完又 /config set key / set baseUrl)时,绑定信息会用空/旧的地址与密钥,
|
|
419
|
+
// 表现是「工具清单是新的,绑定信息却是旧的,甚至误报『未配置网关地址或密钥』」(实测踩到)。
|
|
420
|
+
if (cfg.baseUrl || cfg.key) configureMcpAdmin({ baseUrl: cfg.baseUrl, key: cfg.key });
|
|
421
|
+
const argv = tokenize(String(arg || ''));
|
|
422
|
+
const head = String(argv[0] || '').toLowerCase();
|
|
423
|
+
if (!head || head === 'help' || head === '?') return mcpOverview(ctx, cfg);
|
|
424
|
+
if (head === 'refresh') return mcpRefresh(ctx, cfg, true);
|
|
425
|
+
// 认不出的**第一个词**按子命令处理并给出正确提示;认不出的后续参数交给各子命令自己报错
|
|
426
|
+
if (MCP_SUBCOMMANDS.includes(head)) {
|
|
427
|
+
if (head === 'bind') return mcpBind(ctx, cfg, argv.slice(1));
|
|
428
|
+
if (head === 'mode') return mcpMode(ctx, cfg, argv.slice(1));
|
|
429
|
+
if (head === 'add') return mcpAdd(ctx, cfg, argv.slice(1));
|
|
430
|
+
if (head === 'rm') return mcpRemove(ctx, cfg, argv.slice(1));
|
|
431
|
+
}
|
|
432
|
+
if (/^[+-=]?\d/.test(head)) {
|
|
433
|
+
ctx.say(pick(` · 不认识的参数:${head} 要改绑定请写 /mcp bind ${head}`,
|
|
434
|
+
` · unknown argument: ${head} — to change bindings write /mcp bind ${head}`));
|
|
435
|
+
}
|
|
436
|
+
return mcpOverview(ctx, cfg);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** 总览:本密钥绑了什么 / 可自助加什么 / 我自建了什么 + 工具清单(**不含地址与凭据**) */
|
|
440
|
+
async function mcpOverview(ctx, cfg) {
|
|
441
|
+
const b = await getMcpBinding();
|
|
442
|
+
if (b.ok) {
|
|
443
|
+
ctx.say(pick(`绑定模式:${b.mode || '未报'} 总开关:${b.enabled ? '开' : '关'}`,
|
|
444
|
+
`Binding mode: ${b.mode || 'n/a'} master switch: ${b.enabled ? 'on' : 'off'}`));
|
|
445
|
+
const row = (label, list, empty) => {
|
|
446
|
+
ctx.say(pick(`${label}(${list.length})`, `${label} (${list.length})`));
|
|
447
|
+
if (!list.length) { ctx.say(` ${empty}`); return; }
|
|
448
|
+
list.forEach((s) => ctx.say(` ${serverLine(s)}`));
|
|
449
|
+
};
|
|
450
|
+
row(pick('已绑定', 'Bound'), b.bound, pick('(没有:/mcp bind +<id> 加上一台)', '(none: /mcp bind +<id> to add one)'));
|
|
451
|
+
row(pick('可自助加', 'Addable'), b.addable, pick('(没有:管理员没把服务器标成可自助)', '(none: no server is marked self-service)'));
|
|
452
|
+
row(pick('我自建的', 'Registered by me'), b.mine, pick('(没有)', '(none)'));
|
|
453
|
+
ctx.say(' ' + selfServerText(b));
|
|
454
|
+
if (b.selfServer.allowed) {
|
|
455
|
+
ctx.say(pick(' · 登记自己的:/mcp add <名字> <URL> 删掉:/mcp rm <名字|id>',
|
|
456
|
+
' · register your own: /mcp add <name> <URL> remove: /mcp rm <name|id>'));
|
|
457
|
+
ctx.say(pick(' · 只支持 http/https 且必须是公网可达地址(指向本机/内网的会被网关拒绝)',
|
|
458
|
+
' · only public http/https addresses (loopback/private ones are rejected by the gateway)'));
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
// 管理面失败**不**连累清单:清单走的是另一条 best-effort 路径,网关老版本没有这个接口时
|
|
462
|
+
// 也照样能看工具。所以这里只把原因说清,然后继续拉清单。
|
|
463
|
+
ctx.say(pick(`绑定信息不可用:${b.message}`, `binding info unavailable: ${b.message}`));
|
|
464
|
+
if (b.hint) ctx.say(` · ${b.hint}`);
|
|
465
|
+
}
|
|
466
|
+
return mcpRefresh(ctx, cfg, false);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 拉清单并打印(refresh 立即重拉,否则按 TTL)。失败文案要能区分「没发请求」与「发了失败」 */
|
|
470
|
+
async function mcpRefresh(ctx, cfg, force) {
|
|
471
|
+
// refreshMcpTools 永不抛(best-effort):网关没开 MCP / 断网也只记一行原因
|
|
472
|
+
const info = await refreshMcpTools({ force, baseUrl: cfg.baseUrl, key: cfg.key });
|
|
473
|
+
ctx.say(mcpStatusText());
|
|
474
|
+
for (const t of mcpToolList()) {
|
|
475
|
+
const what = [t.server, t.tool].filter(Boolean).join('/');
|
|
476
|
+
const brief = String(t.description || '').split('\n')[0].slice(0, 60);
|
|
477
|
+
ctx.say(` ${t.name}${what ? ` [${what}]` : ''}${brief ? ` ${brief}` : ''}`);
|
|
478
|
+
}
|
|
479
|
+
if (info?.error) {
|
|
480
|
+
// 把「发没发请求、为什么没有」直说出来:否则「CLI 没拉清单」(GATEWAY_MCP=off、
|
|
481
|
+
// 密钥配置不全)与「拉了但网关没给」(404/断网)在用户眼里长得一模一样,只能去翻网关日志。
|
|
482
|
+
// attemptedAt 是 lib/mcp.js 在真正发请求时记下的,所以这句是事实而不是推测。
|
|
483
|
+
ctx.say(info.attemptedAt
|
|
484
|
+
? pick(
|
|
485
|
+
` · 已请求 GET ${cfg.baseUrl || '?'}/v1/mcp/tools(${force ? '强制刷新' : '按需'}):${info.error}`,
|
|
486
|
+
` · requested GET ${cfg.baseUrl || '?'}/v1/mcp/tools (${force ? 'forced refresh' : 'on demand'}): ${info.error}`,
|
|
487
|
+
)
|
|
488
|
+
: pick(` · 未向网关发出请求:${info.error}`, ` · no request sent to the gateway: ${info.error}`));
|
|
489
|
+
// GATEWAY_MCP=off 是用户自己关的,再让他去查网关就是误导
|
|
490
|
+
if (!/GATEWAY_MCP=off/.test(info.error)) {
|
|
491
|
+
ctx.say(pick(' · 常见原因:网关未开启 MCP / 该密钥未绑定任何工具 / 网关版本不带这个接口', ' · usual causes: MCP is off on the gateway / this key has no tools bound / the gateway version lacks this endpoint'));
|
|
492
|
+
ctx.say(pick(' · 看细节:GATEWAY_MCP_DEBUG=1 重跑,每次拉取与调用都会打到 stderr', ' · for details: rerun with GATEWAY_MCP_DEBUG=1; every fetch and call is traced to stderr'));
|
|
493
|
+
}
|
|
494
|
+
} else if (force) {
|
|
495
|
+
ctx.say(pick(' · 已强制重拉(后续对话立即用这份新清单)', ' · force-refreshed (later turns use this new list immediately)'));
|
|
496
|
+
} else {
|
|
497
|
+
ctx.say(pick(' · 清单来自网关 GET /v1/mcp/tools(60 秒 TTL);/mcp refresh 可立即重拉', ' · list comes from the gateway GET /v1/mcp/tools (60s TTL); /mcp refresh re-fetches now'));
|
|
498
|
+
}
|
|
499
|
+
ctx.say(pick(` · 改绑:/mcp bind +<id> -<id> 或 =<id,...> 改模式:/mcp mode off|inject|loop 看全部:/help mcp`,
|
|
500
|
+
` · bindings: /mcp bind +<id> -<id> or =<id,...> mode: /mcp mode off|inject|loop all: /help mcp`));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** `/mcp bind +3 -5` 或 `/mcp bind =2,7`:在我可选的范围内改绑 */
|
|
504
|
+
async function mcpBind(ctx, cfg, argv) {
|
|
505
|
+
const b = await getMcpBinding();
|
|
506
|
+
if (!b.ok) return mcpAdminFailed(ctx, b, pick('改绑未执行', 'binding not changed'));
|
|
507
|
+
if (!argv.length) {
|
|
508
|
+
ctx.say(pick('用法:/mcp bind +<id> -<id>(加减)或 /mcp bind =<id,...>(整份设定)',
|
|
509
|
+
'usage: /mcp bind +<id> -<id> (add/remove) or /mcp bind =<id,...> (set all)'));
|
|
510
|
+
ctx.say(pick(` 当前绑定:${b.bound.length ? b.bound.map((s) => `#${s.id}`).join(' ') : '(空)'}`,
|
|
511
|
+
` current: ${b.bound.length ? b.bound.map((s) => `#${s.id}`).join(' ') : '(empty)'}`));
|
|
512
|
+
ctx.say(pick(` 可选 id:${b.selectableIds.join(' ') || '(无)'}`, ` selectable ids: ${b.selectableIds.join(' ') || '(none)'}`));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
let want;
|
|
516
|
+
const setAll = argv.find((a) => String(a).startsWith('='));
|
|
517
|
+
if (setAll) {
|
|
518
|
+
want = String(setAll).slice(1).split(',').map((s) => Number(String(s).trim())).filter((n) => Number.isFinite(n));
|
|
519
|
+
} else {
|
|
520
|
+
// 不认识的记号(例如 `3` 少打了 +)必须报错:静默忽略会让用户以为「加上了」
|
|
521
|
+
const unknown = argv.filter((a) => !/^[+-]\d+$/.test(String(a)));
|
|
522
|
+
if (unknown.length) {
|
|
523
|
+
ctx.say(pick(`参数要写成 +id / -id(缺了 +/- 的:${unknown.join(' ')})`,
|
|
524
|
+
`arguments must be +id / -id (missing +/-, got: ${unknown.join(' ')})`));
|
|
525
|
+
ctx.say(pick(' 例:/mcp bind +3 -5 或 /mcp bind =2,7', ' e.g. /mcp bind +3 -5 or /mcp bind =2,7'));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const cur = b.bound.map((s) => Number(s.id));
|
|
529
|
+
const out = cur.slice();
|
|
530
|
+
argv.forEach((a) => {
|
|
531
|
+
const s = String(a);
|
|
532
|
+
const id = Number(s.slice(1));
|
|
533
|
+
if (s[0] === '+') { if (!out.includes(id)) out.push(id); }
|
|
534
|
+
else { const i = out.indexOf(id); if (i >= 0) out.splice(i, 1); }
|
|
535
|
+
});
|
|
536
|
+
want = out;
|
|
537
|
+
}
|
|
538
|
+
// 越权/不存在的 id 本地先拦:网关也会整单拒绝(并点名),但白跑一趟没必要
|
|
539
|
+
const bad = want.filter((id) => !b.selectableIds.includes(id));
|
|
540
|
+
if (bad.length) {
|
|
541
|
+
ctx.say(pick(`这几个不在你可选的范围内:${bad.join(' ')}`, `not in your selectable range: ${bad.join(' ')}`));
|
|
542
|
+
ctx.say(pick(` 可选 id:${b.selectableIds.join(' ') || '(无)'} 可自助加的:${b.addable.map((s) => `#${s.id} ${s.name}`).join(',') || '(无)'}`,
|
|
543
|
+
` selectable ids: ${b.selectableIds.join(' ') || '(none)'} addable: ${b.addable.map((s) => `#${s.id} ${s.name}`).join(', ') || '(none)'}`));
|
|
544
|
+
ctx.say(pick(' 想从零加一台新的:/mcp add <名字> <URL>(或让管理员把它标成可自助)',
|
|
545
|
+
' to add a brand-new one: /mcp add <name> <URL> (or ask an admin to mark it self-service)'));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const r = await patchMcpBinding({ serverIds: want });
|
|
549
|
+
if (!r.ok) return mcpAdminFailed(ctx, r, pick('改绑未生效', 'binding not changed'));
|
|
550
|
+
const now = Array.isArray(r.data?.bound) ? r.data.bound : [];
|
|
551
|
+
ctx.say(pick(`已改绑:现在 ${now.length} 台${now.length ? `(${now.map((s) => `#${s.id} ${s.name}`).join(',')})` : ''}`,
|
|
552
|
+
`bindings updated: ${now.length} server(s)${now.length ? ` (${now.map((s) => `#${s.id} ${s.name}`).join(', ')})` : ''}`));
|
|
553
|
+
await mcpRefresh(ctx, cfg, true);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** `/mcp mode off|inject|loop`:只改模式。**不下发 server_ids**(那会把绑定解掉) */
|
|
557
|
+
async function mcpMode(ctx, cfg, argv) {
|
|
558
|
+
const m = String(argv[0] || '').toLowerCase();
|
|
559
|
+
if (!['off', 'inject', 'loop'].includes(m)) {
|
|
560
|
+
ctx.say(pick('用法:/mcp mode off|inject|loop', 'usage: /mcp mode off|inject|loop'));
|
|
561
|
+
ctx.say(pick(' off:本密钥不用 MCP(工具仍可列、仍可由 CLI 执行) inject:只把工具定义注入上游 loop:网关代执行',
|
|
562
|
+
' off: this key does not use MCP (tools can still be listed/executed by the CLI) inject: definitions injected upstream loop: gateway executes'));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const r = await patchMcpBinding({ mode: m });
|
|
566
|
+
if (!r.ok) return mcpAdminFailed(ctx, r, pick('模式未改', 'mode not changed'));
|
|
567
|
+
const bound = Array.isArray(r.data?.bound) ? r.data.bound : [];
|
|
568
|
+
ctx.say(pick(`模式已改为 ${m}(绑定未动:${bound.length} 台)`, `mode set to ${m} (bindings untouched: ${bound.length})`));
|
|
569
|
+
await mcpRefresh(ctx, cfg, true);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `/mcp add <名字> <URL> [--header "k: v"]...`:登记自己的 HTTP MCP 服务器(BYO-A) */
|
|
573
|
+
async function mcpAdd(ctx, cfg, argv) {
|
|
574
|
+
let name = '';
|
|
575
|
+
let url = '';
|
|
576
|
+
const rest = [];
|
|
577
|
+
for (let i = 0; i < argv.length; i++) {
|
|
578
|
+
const a = String(argv[i]);
|
|
579
|
+
if (a === '--header' || a === '-H' || /^--header=/i.test(a)) { rest.push(a); continue; }
|
|
580
|
+
if (!name) name = a;
|
|
581
|
+
else if (!url) url = a;
|
|
582
|
+
else rest.push(a);
|
|
583
|
+
}
|
|
584
|
+
// 少参数时:**有交互通道就问**,没有就直接说用法(不静默取默认值)
|
|
585
|
+
const hasAsk = typeof ctx.ask === 'function';
|
|
586
|
+
if (!name && hasAsk) name = String((await ctx.ask(pick('服务器名(用于绑定与识别,全局唯一):', 'server name (globally unique, used for binding): '))) || '').trim();
|
|
587
|
+
if (!url && hasAsk) url = String((await ctx.ask(pick('MCP 地址(http/https 公网可达):', 'MCP URL (public http/https): '))) || '').trim();
|
|
588
|
+
if (!name || !url) {
|
|
589
|
+
ctx.say(pick('用法:/mcp add <名字> <URL> [--header "Authorization: Bearer xxx"]',
|
|
590
|
+
'usage: /mcp add <name> <URL> [--header "Authorization: Bearer xxx"]'));
|
|
591
|
+
ctx.say(pick(' · 只支持 http/https,且必须是**公网可达**地址(指向本机/内网的会被网关拒绝)',
|
|
592
|
+
' · http/https only, and the address must be publicly reachable (loopback/private ones are rejected)'));
|
|
593
|
+
ctx.say(pick(' · 凭据可以放在查询参数里(?key=…);CLI 只显示掩码,明文存在网关侧',
|
|
594
|
+
' · credentials may live in the query string (?key=…); the CLI only shows a masked form, the plaintext is kept on the gateway'));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const { headers, bad } = parseHeaderArgs(rest);
|
|
598
|
+
if (bad.length) {
|
|
599
|
+
ctx.say(pick(`--header 要写成 "名字: 值"(这两条不合法:${bad.join(' | ')})`,
|
|
600
|
+
`--header must be "name: value" (invalid: ${bad.join(' | ')})`));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// 先读一次绑定:① 判据来自网关(能不能自助登记)② allowed=false 时**不发那次注定 403 的请求**
|
|
604
|
+
const b = await getMcpBinding();
|
|
605
|
+
if (b.ok && !b.selfServer.allowed) {
|
|
606
|
+
ctx.say(pick('网关未开放自助登记:需要部署方在 .env 设 MCP_ALLOW_SELF_SERVERS=true 后**重启网关**(重启才生效)',
|
|
607
|
+
'the gateway has self-registration closed: the operator must set MCP_ALLOW_SELF_SERVERS=true in .env and restart the gateway'));
|
|
608
|
+
ctx.say(pick(' · 现在仍可用管理员登记好的、标了「可自助」的服务器:/mcp bind +<id>',
|
|
609
|
+
' · you can still bind admin-registered servers marked self-service: /mcp bind +<id>'));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (b.ok && b.selfServer.max && b.selfServer.used >= b.selfServer.max) {
|
|
613
|
+
ctx.say(pick(`自助登记已满(${b.selfServer.used}/${b.selfServer.max}):先 /mcp rm 掉不用的`,
|
|
614
|
+
`self-registration is full (${b.selfServer.used}/${b.selfServer.max}): /mcp rm an unused one first`));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// 明文 URL 只在这一行内部存在:发给网关是必须的,打印出来的每一处都过掩码
|
|
618
|
+
ctx.say(pick(`登记中:${name} ← ${maskUrl(url)}${Object.keys(headers).length ? `(自定义头:${Object.keys(headers).join(', ')},值不显示)` : ''}`,
|
|
619
|
+
`registering: ${name} ← ${maskUrl(url)}${Object.keys(headers).length ? ` (headers: ${Object.keys(headers).join(', ')}, values hidden)` : ''}`));
|
|
620
|
+
if (urlHasCredential(url)) {
|
|
621
|
+
ctx.say(pick(' · 这个地址带凭据(查询参数):CLI 只显示掩码,明文保存在网关侧(与管理员登记的服务器同一套机制)',
|
|
622
|
+
' · this URL carries credentials in its query string: the CLI only shows a masked form; the plaintext is stored on the gateway'));
|
|
623
|
+
}
|
|
624
|
+
const r = await createMcpServer({ name, url, headers });
|
|
625
|
+
if (!r.ok) return mcpAdminFailed(ctx, r, pick('登记未完成', 'registration failed'));
|
|
626
|
+
const s = r.server || {};
|
|
627
|
+
ctx.say(pick(`已登记 #${s.id} ${s.name}(工具 ${Number(s.tool_count || 0)} 个${s.enabled === false ? ',已置为停用' : ''}),并已绑定到本密钥`,
|
|
628
|
+
`registered #${s.id} ${s.name} (${Number(s.tool_count || 0)} tools${s.enabled === false ? ', disabled' : ''}) and bound to this key`));
|
|
629
|
+
if (r.probeError) {
|
|
630
|
+
// 探测失败 ≠ 登记失败:网关留着行、置成停用(地址可能只是暂时不可达)
|
|
631
|
+
ctx.say(pick(` · 探测失败(这台已置为停用):${r.probeError}`, ` · probe failed (server disabled): ${r.probeError}`));
|
|
632
|
+
ctx.say(pick(' · 改完地址后到管理页或重新登记一次探测即可启用', ' · fix the address and probe again to enable it'));
|
|
633
|
+
} else if (r.note) {
|
|
634
|
+
ctx.say(` · ${r.note}`);
|
|
635
|
+
}
|
|
636
|
+
await mcpRefresh(ctx, cfg, true);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** `/mcp rm <名字|id>`:删掉**自己登记**的服务器。先确认,且只认 mine 里的 */
|
|
640
|
+
async function mcpRemove(ctx, cfg, argv) {
|
|
641
|
+
const key = String(argv[0] || '').trim();
|
|
642
|
+
const b = await getMcpBinding();
|
|
643
|
+
if (!b.ok) return mcpAdminFailed(ctx, b, pick('删除未执行', 'delete not executed'));
|
|
644
|
+
if (!key) {
|
|
645
|
+
ctx.say(pick('用法:/mcp rm <名字|id>(只能删你自己登记的)', 'usage: /mcp rm <name|id> (only your own registrations)'));
|
|
646
|
+
ctx.say(pick(` 你登记的:${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(',') : '(没有)'}`,
|
|
647
|
+
` yours: ${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(', ') : '(none)'}`));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const target = /^\d+$/.test(key)
|
|
651
|
+
? b.mine.find((s) => Number(s.id) === Number(key))
|
|
652
|
+
: b.mine.find((s) => String(s.name) === key);
|
|
653
|
+
if (!target) {
|
|
654
|
+
// 刻意不区分「不是你的」与「不存在」:与网关的 404 同一态度(不给出存在性信号)
|
|
655
|
+
ctx.say(pick(`你登记的服务器里没有 ${key}`, `no registration of yours matches ${key}`));
|
|
656
|
+
ctx.say(pick(` 你登记的:${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(',') : '(没有)'}`,
|
|
657
|
+
` yours: ${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(', ') : '(none)'}`));
|
|
658
|
+
if (!b.selfServer.allowed) ctx.say(' ' + selfServerText(b));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// 删是不可逆的:能问就问一句。没有交互通道(非 TTY)时**不删**,让用户写成确定的参数
|
|
662
|
+
if (typeof ctx.ask === 'function') {
|
|
663
|
+
const a = String((await ctx.ask(pick(`确认删除 #${target.id} ${target.name}?(y/N)`, `delete #${target.id} ${target.name}? (y/N) `))) || '').trim().toLowerCase();
|
|
664
|
+
if (a !== 'y' && a !== 'yes') {
|
|
665
|
+
ctx.say(pick('已取消(没有删除任何东西)', 'cancelled (nothing was deleted)'));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
ctx.say(pick('非交互环境不自动删:请把名字或 id 写全(/mcp rm <名字|id>)',
|
|
670
|
+
'refusing to delete in a non-interactive environment: pass the name or id explicitly (/mcp rm <name|id>)'));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const r = await deleteMcpServer(target.id);
|
|
674
|
+
if (!r.ok) return mcpAdminFailed(ctx, r, pick('删除未完成', 'delete failed'));
|
|
675
|
+
ctx.say(r.message);
|
|
676
|
+
if (r.note) ctx.say(` · ${r.note}`);
|
|
677
|
+
await mcpRefresh(ctx, cfg, true);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** 管理面失败的统一出口:原因 + 能照着修的一句(hint 来自 lib/mcpadmin.js) */
|
|
681
|
+
function mcpAdminFailed(ctx, r, what) {
|
|
682
|
+
ctx.say(pick(`${what}:${r.message}`, `${what}: ${r.message}`));
|
|
683
|
+
if (r.hint) ctx.say(` · ${r.hint}`);
|
|
684
|
+
}
|
|
685
|
+
|
|
398
686
|
/** 表里的全部主名,按定义顺序(帮助、补全、测试都用它)。 */
|
|
399
687
|
export const COMMAND_NAMES = Object.keys(COMMANDS);
|
|
400
688
|
|
|
401
|
-
/**
|
|
402
|
-
|
|
689
|
+
/**
|
|
690
|
+
* 机器可读的命令行(跨端契约断言用;字段与网关侧 commandRows() 一致)。
|
|
691
|
+
*
|
|
692
|
+
* `lang` **默认 zh**:网关用它生成 `/api/commands` 喂给中文手册页,
|
|
693
|
+
* 不能被 `node server.js --lang en` 带跑偏(那样手册正文是中文、表格是英文)。
|
|
694
|
+
*/
|
|
695
|
+
export function commandRows(registry = COMMANDS, lang = 'zh') {
|
|
696
|
+
const en = lang === 'en';
|
|
403
697
|
return Object.keys(registry).map((key) => {
|
|
404
698
|
const c = registry[key];
|
|
699
|
+
const e = (en && c.en) || {};
|
|
405
700
|
return {
|
|
406
701
|
name: c.name,
|
|
407
702
|
aliases: (c.aliases || []).slice(),
|
|
408
|
-
args: c.args || '—',
|
|
409
|
-
usage: c.usage,
|
|
410
|
-
help: c.desc,
|
|
411
|
-
group: c.group,
|
|
703
|
+
args: e.args || c.args || '—',
|
|
704
|
+
usage: e.usage || c.usage,
|
|
705
|
+
help: e.desc || c.desc,
|
|
706
|
+
group: e.group || c.group,
|
|
412
707
|
capability: c.capability,
|
|
413
708
|
surfaces: (c.surfaces || []).slice(),
|
|
414
709
|
needsArgs: !!c.needsArgs,
|
|
@@ -416,51 +711,83 @@ export function commandRows(registry = COMMANDS) {
|
|
|
416
711
|
});
|
|
417
712
|
}
|
|
418
713
|
|
|
714
|
+
/**
|
|
715
|
+
* 表内取词:按**传入的 lang** 取,而不是进程语言 ——
|
|
716
|
+
* 否则 `helpText(x, 'zh')` 会出现「标题中文、正文英文」这种半中半英(两处语言来源不同)。
|
|
717
|
+
*/
|
|
718
|
+
const byLang = (lang, zh, en) => (lang === 'en' && en ? en : zh);
|
|
719
|
+
|
|
419
720
|
/** 能力标记(写给终端看的,不是机器口径)。 */
|
|
420
|
-
function capNote(c) {
|
|
421
|
-
if (c.capability === 'fs') return '(读写本地文件)';
|
|
422
|
-
if (c.capability === 'api') return '(需网关接口)';
|
|
721
|
+
function capNote(c, lang = 'zh') {
|
|
722
|
+
if (c.capability === 'fs') return byLang(lang, '(读写本地文件)', ' (reads/writes local files)');
|
|
723
|
+
if (c.capability === 'api') return byLang(lang, '(需网关接口)', ' (needs the gateway API)');
|
|
423
724
|
return '';
|
|
424
725
|
}
|
|
425
726
|
|
|
426
727
|
/**
|
|
427
728
|
* 帮助文本:无参数 = 分组总表;带参数 = 单条用法。
|
|
428
729
|
* 不做 Markdown(终端里难看),也不做候选面板(无 raw mode)。
|
|
730
|
+
*
|
|
731
|
+
* `lang` 默认取**当前进程语言**:只有 CLI 的 `/help` 调它(Web 侧走 commandRows,默认 zh)。
|
|
732
|
+
* 整段(含标题与脚注)都由同一个 lang 决定,不混用 pick()。
|
|
429
733
|
*/
|
|
430
|
-
export function helpText(name) {
|
|
734
|
+
export function helpText(name, lang = getLang()) {
|
|
735
|
+
const L = (zh, en) => byLang(lang, zh, en);
|
|
736
|
+
/** 表里那条命令在当前语言下的元数据 */
|
|
737
|
+
const meta = (c) => {
|
|
738
|
+
const e = (lang === 'en' && c.en) || {};
|
|
739
|
+
return {
|
|
740
|
+
usage: e.usage || c.usage,
|
|
741
|
+
desc: e.desc || c.desc,
|
|
742
|
+
group: e.group || c.group || L('其他', 'Other'),
|
|
743
|
+
args: e.args || c.args,
|
|
744
|
+
};
|
|
745
|
+
};
|
|
431
746
|
if (name) {
|
|
432
747
|
const bare = String(name).replace(/^\//, '');
|
|
433
748
|
const key = '/' + bare.toLowerCase();
|
|
434
749
|
const cmd = COMMANDS[key] || COMMANDS[findByAlias(bare.toLowerCase()) || ''];
|
|
435
|
-
if (!cmd)
|
|
750
|
+
if (!cmd) {
|
|
751
|
+
return L(`没有这条命令:${name}(用 /help 看全部)`, `No such command: ${name} (use /help to list all)`);
|
|
752
|
+
}
|
|
753
|
+
const m = meta(cmd);
|
|
436
754
|
const lines = [
|
|
437
|
-
`${
|
|
438
|
-
|
|
439
|
-
|
|
755
|
+
`${m.usage} ${m.desc}`,
|
|
756
|
+
L(
|
|
757
|
+
` 别名:${(cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'}`,
|
|
758
|
+
` aliases: ${(cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'}`,
|
|
759
|
+
),
|
|
760
|
+
L(
|
|
761
|
+
` 能力:${cmd.capability}${capNote(cmd, lang)} 端:${(cmd.surfaces || []).join(' / ')}`,
|
|
762
|
+
` capability: ${cmd.capability}${capNote(cmd, lang)} surfaces: ${(cmd.surfaces || []).join(' / ')}`,
|
|
763
|
+
),
|
|
440
764
|
];
|
|
441
|
-
if (cmd.bare) lines.push(` 裸词也可以:${cmd.name}`);
|
|
765
|
+
if (cmd.bare) lines.push(L(` 裸词也可以:${cmd.name}`, ` the bare word works too: ${cmd.name}`));
|
|
442
766
|
return lines.join('\n');
|
|
443
767
|
}
|
|
444
|
-
const width = Math.max(...COMMAND_NAMES.map((n) => COMMANDS[n].usage.length));
|
|
768
|
+
const width = Math.max(...COMMAND_NAMES.map((n) => meta(COMMANDS[n]).usage.length));
|
|
445
769
|
const groups = [];
|
|
446
770
|
COMMAND_NAMES.forEach((n) => {
|
|
447
|
-
const g = COMMANDS[n].group
|
|
771
|
+
const g = meta(COMMANDS[n]).group;
|
|
448
772
|
if (!groups.includes(g)) groups.push(g);
|
|
449
773
|
});
|
|
450
774
|
const rows = [];
|
|
451
775
|
groups.forEach((g) => {
|
|
452
776
|
rows.push(` ${g}`);
|
|
453
|
-
COMMAND_NAMES.filter((n) => (COMMANDS[n].group
|
|
454
|
-
const
|
|
455
|
-
rows.push(` ${
|
|
777
|
+
COMMAND_NAMES.filter((n) => meta(COMMANDS[n]).group === g).forEach((n) => {
|
|
778
|
+
const m = meta(COMMANDS[n]);
|
|
779
|
+
rows.push(` ${m.usage.padEnd(width)} ${m.desc}`);
|
|
456
780
|
});
|
|
457
781
|
});
|
|
458
782
|
return [
|
|
459
|
-
|
|
783
|
+
L(
|
|
784
|
+
'斜杠命令(/help <命令名> 看单条用法;同名命令与网关 Web 端语义一致):',
|
|
785
|
+
'Slash commands (/help <command> for one command; same-name commands mean the same on the gateway web UI):',
|
|
786
|
+
),
|
|
460
787
|
...rows,
|
|
461
788
|
'',
|
|
462
|
-
'其它输入直接交给模型;行尾写 \\ 可续行。',
|
|
463
|
-
'想让 /clear 这样的文字原样发给模型:写成 //clear。',
|
|
789
|
+
L('其它输入直接交给模型;行尾写 \\ 可续行。', 'Anything else goes straight to the model; end a line with \\ to continue it.'),
|
|
790
|
+
L('想让 /clear 这样的文字原样发给模型:写成 //clear。', 'To send text like /clear to the model verbatim, write //clear.'),
|
|
464
791
|
].join('\n');
|
|
465
792
|
}
|
|
466
793
|
|