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/cli-agent.js
CHANGED
|
@@ -27,7 +27,7 @@ import { spawn } from 'node:child_process';
|
|
|
27
27
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
28
|
|
|
29
29
|
import { loadDotEnv, parseArgs, DEFAULT_BASE_URL, maskKey } from './lib/common.js';
|
|
30
|
-
import { resolveConfig, numArg, fail,
|
|
30
|
+
import { resolveConfig, numArg, fail, keyHint } from './lib/config.js';
|
|
31
31
|
import { checkWorkDir, setBashEnabled, availableTools } from './lib/tools.js';
|
|
32
32
|
import { createSession, restoreSession, runTurn, resumePendingTurn, countTurns } from './lib/runner.js';
|
|
33
33
|
import { createSessionStore, resolveSessionDir, SESSION_TTL_MS } from './lib/sessionstore.js';
|
|
@@ -40,6 +40,8 @@ import { secretSourceText } from './lib/secrets.js';
|
|
|
40
40
|
import { AGENT_LIMITS } from './lib/agent.js';
|
|
41
41
|
import { MEMORY_FILES, readMemory } from './lib/memory.js';
|
|
42
42
|
import { refreshMcpTools, mcpStatusText } from './lib/mcp.js';
|
|
43
|
+
// CLI 侧语言(只覆盖命令行输出;Web 界面与手册页仍是中文)。默认 zh,见 lib/i18n.js
|
|
44
|
+
import { pick, setLang, resolveLang } from './lib/i18n.js';
|
|
43
45
|
|
|
44
46
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
45
47
|
loadDotEnv(SCRIPT_DIR);
|
|
@@ -47,7 +49,7 @@ loadDotEnv(SCRIPT_DIR);
|
|
|
47
49
|
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
48
50
|
const HISTORY_LIMIT = 200;
|
|
49
51
|
|
|
50
|
-
const
|
|
52
|
+
const HELP_ZH = `用法:gateway-agent 启动器:先问「对话 / 任务」,再问「命令行 / 网页」
|
|
51
53
|
gateway-agent [选项] [提示词]
|
|
52
54
|
gateway-agent setup [--base-url <地址>] [--key sk-xxx] 首次配置引导
|
|
53
55
|
gateway-agent config <list|get|set|unset|path> [键] [值]
|
|
@@ -62,6 +64,7 @@ const HELP = `用法:gateway-agent 启动器:先问「对
|
|
|
62
64
|
http://127.0.0.1:3100/ 聊天页
|
|
63
65
|
http://127.0.0.1:3100/task 任务页(「设置」里可改网关地址 / 模型 / 密钥)
|
|
64
66
|
http://127.0.0.1:3100/manual 操作手册页(安装、首次配置、指令一览的完整版)
|
|
67
|
+
http://127.0.0.1:3100/sessions 命令行会话的只读存档(就是这里聊过的对话,--resume 接着聊)
|
|
65
68
|
|
|
66
69
|
子命令:
|
|
67
70
|
setup 首次配置引导:问一次「网关地址」与「网关密钥」并写进本机配置
|
|
@@ -93,11 +96,73 @@ const HELP = `用法:gateway-agent 启动器:先问「对
|
|
|
93
96
|
--yes 自动批准所有**文件写入**(危险:模型可直接改文件,慎用)。MCP 调用不需要它
|
|
94
97
|
--verbose 打印工具结果正文(默认只显示一行摘要)
|
|
95
98
|
--no-color 关闭彩色输出(也遵循 NO_COLOR 环境变量)
|
|
99
|
+
--lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)
|
|
96
100
|
-h, --help 显示帮助
|
|
97
101
|
|
|
98
102
|
单个任务最多 ${AGENT_LIMITS.MAX_STEPS} 轮工具调用(--max-steps 可调)。写入被拒后模型会改为向你说明思路。
|
|
99
103
|
`;
|
|
100
104
|
|
|
105
|
+
const HELP_EN = `Usage: gateway-agent launcher: asks "chat / task", then "terminal / web"
|
|
106
|
+
gateway-agent [options] [prompt]
|
|
107
|
+
gateway-agent setup [--base-url <url>] [--key sk-xxx] first-time setup
|
|
108
|
+
gateway-agent config <list|get|set|unset|path> [key] [value]
|
|
109
|
+
(alias of the same entry point: llm-api-gateway-cli; from the sources: node cli-agent.js …)
|
|
110
|
+
|
|
111
|
+
Mode 6 · native agent: brings its own tool loop, reads and writes files inside the working
|
|
112
|
+
directory and asks before every write. No Claude Code required (no @anthropic-ai/claude-code).
|
|
113
|
+
|
|
114
|
+
Related commands (local Web UI — this command never opens a browser for you):
|
|
115
|
+
gateway-task [--port 3100] start the local Web UI and task page (gateway-web is an alias);
|
|
116
|
+
it starts without an API key; set the gateway URL and key on the
|
|
117
|
+
task page under "Settings"
|
|
118
|
+
http://127.0.0.1:3100/ chat
|
|
119
|
+
http://127.0.0.1:3100/task task mode (gateway URL / model / key are editable in "Settings")
|
|
120
|
+
http://127.0.0.1:3100/manual operation manual (install, first-time setup, full command list)
|
|
121
|
+
http://127.0.0.1:3100/sessions read-only archive of CLI sessions (conversations started here)
|
|
122
|
+
|
|
123
|
+
Subcommands:
|
|
124
|
+
setup first-time setup: asks once for the gateway URL and the key, then writes
|
|
125
|
+
them to your machine (URL -> config.json, key -> credentials.json).
|
|
126
|
+
With --base-url / --key it writes without asking; a fresh machine enters
|
|
127
|
+
this step automatically on the first run.
|
|
128
|
+
config list|get|set|unset|path
|
|
129
|
+
view or change persisted settings (stored on disk, survives cache
|
|
130
|
+
clearing). config list marks where each effective value comes from:
|
|
131
|
+
command line, environment, config file or built-in default — that column
|
|
132
|
+
tells you what is overriding the value you just set.
|
|
133
|
+
|
|
134
|
+
Options:
|
|
135
|
+
-p, --prompt <text> one-shot task (falls back to the positional argument / stdin)
|
|
136
|
+
-i, --interactive multi-turn interactive mode (REPL)
|
|
137
|
+
-C, --cwd <dir> working directory; tools are sandboxed inside it (default: current dir)
|
|
138
|
+
-m, --model <model> model name (default ${DEFAULT_MODEL})
|
|
139
|
+
-k, --key <sk-key> gateway API key (or env SK/GATEWAY_KEY/OPENAI_API_KEY, or .env)
|
|
140
|
+
--base-url <url> gateway base URL (default ${DEFAULT_BASE_URL})
|
|
141
|
+
-s, --system <text> extra system prompt
|
|
142
|
+
--temperature <v> sampling temperature
|
|
143
|
+
--max-tokens <v> max generated tokens (a thinking model given too few may only emit reasoning)
|
|
144
|
+
--max-steps <n> max model turns per task (default ${AGENT_LIMITS.MAX_STEPS})
|
|
145
|
+
--continue continue the most recent session (context + pending approval)
|
|
146
|
+
--resume <session-id> resume the given session (UUID)
|
|
147
|
+
--session-dir <dir> where sessions are written (default: .llm-api-gateway-cli in your home dir)
|
|
148
|
+
--no-session do not persist the session (memory only, lost on exit)
|
|
149
|
+
--no-memory do not read AGENTS.md / CLAUDE.md project memory
|
|
150
|
+
--allow-bash enable the bash tool (off by default; each command still needs approval)
|
|
151
|
+
--output-format <v> output format: text (default) or stream-json (one JSON event per line)
|
|
152
|
+
--yes auto-approve all **file writes** (dangerous: the model edits files directly). MCP calls never need it
|
|
153
|
+
--verbose print tool output in full (default: a one-line summary)
|
|
154
|
+
--no-color disable colors (also honors the NO_COLOR env var)
|
|
155
|
+
--lang <zh|en> output language (default zh; env LLM_GATEWAY_LANG)
|
|
156
|
+
-h, --help show this help
|
|
157
|
+
|
|
158
|
+
One task runs at most ${AGENT_LIMITS.MAX_STEPS} model turns (--max-steps). After a rejected write the model explains its plan instead.
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
/** 大段帮助取词必须在**调用时**(模块顶层调 pick 会早于 setLang,永远拿到中文) */
|
|
162
|
+
function mainHelp() {
|
|
163
|
+
return pick(HELP_ZH, HELP_EN);
|
|
164
|
+
}
|
|
165
|
+
|
|
101
166
|
const VALUE_FLAGS = {
|
|
102
167
|
'--prompt': 'prompt',
|
|
103
168
|
'-p': 'prompt',
|
|
@@ -116,6 +181,7 @@ const VALUE_FLAGS = {
|
|
|
116
181
|
'--resume': 'resume',
|
|
117
182
|
'--session-dir': 'session-dir',
|
|
118
183
|
'--output-format': 'output-format',
|
|
184
|
+
'--lang': 'lang',
|
|
119
185
|
};
|
|
120
186
|
const BOOL_FLAGS = {
|
|
121
187
|
'--interactive': 'interactive',
|
|
@@ -131,6 +197,14 @@ const BOOL_FLAGS = {
|
|
|
131
197
|
'-h': 'help',
|
|
132
198
|
};
|
|
133
199
|
|
|
200
|
+
/** 从原始 argv 里取 --lang:要在 parseArgs 之前用(setup / config 子命令也会输出) */
|
|
201
|
+
function langFromArgv(argv) {
|
|
202
|
+
const i = argv.indexOf('--lang');
|
|
203
|
+
if (i >= 0 && argv[i + 1] && !String(argv[i + 1]).startsWith('-')) return argv[i + 1];
|
|
204
|
+
const eq = argv.find((a) => String(a).startsWith('--lang='));
|
|
205
|
+
return eq ? String(eq).slice('--lang='.length) : '';
|
|
206
|
+
}
|
|
207
|
+
|
|
134
208
|
function readStdin() {
|
|
135
209
|
return new Promise((resolve) => {
|
|
136
210
|
if (process.stdin.isTTY) return resolve('');
|
|
@@ -233,17 +307,27 @@ function createRenderer({ color, verbose, json }) {
|
|
|
233
307
|
// ---------- 写入批准 ----------
|
|
234
308
|
|
|
235
309
|
function renderApproval(preview, color) {
|
|
236
|
-
const top = '┌─ 待批准写入 ─────────────────────────────';
|
|
310
|
+
const top = pick('┌─ 待批准写入 ─────────────────────────────', '┌─ pending write ──────────────────────────');
|
|
237
311
|
const mid = '├──────────────────────────────────────────';
|
|
238
312
|
const bot = '└──────────────────────────────────────────';
|
|
239
|
-
const kind = preview.shell
|
|
240
|
-
|
|
313
|
+
const kind = preview.shell
|
|
314
|
+
? pick('执行命令', 'run command')
|
|
315
|
+
: preview.patch
|
|
316
|
+
? pick('增量修改', 'patch')
|
|
317
|
+
: preview.exists
|
|
318
|
+
? pick('覆盖已有文件', 'overwrite existing file')
|
|
319
|
+
: pick('新建文件', 'create file');
|
|
320
|
+
const out = [
|
|
321
|
+
top,
|
|
322
|
+
`│ ${preview.path} (${kind})`,
|
|
323
|
+
pick(`│ ${preview.lines} 行 / ${preview.bytes} 字节`, `│ ${preview.lines} lines / ${preview.bytes} bytes`),
|
|
324
|
+
];
|
|
241
325
|
|
|
242
326
|
// 覆盖已有文件时给出行级 diff:只看新内容根本判断不出改了什么
|
|
243
327
|
const canDiff = !preview.shell && preview.exists && typeof preview.old === 'string' && preview.old !== preview.content;
|
|
244
328
|
if (canDiff) {
|
|
245
329
|
const { lines, removed, added } = diffLines(preview.old, preview.content);
|
|
246
|
-
out.push(mid, `│ 改动:-${removed} 行 / +${added}
|
|
330
|
+
out.push(mid, pick(`│ 改动:-${removed} 行 / +${added} 行`, `│ changes: -${removed} lines / +${added} lines`));
|
|
247
331
|
for (const line of lines) {
|
|
248
332
|
const painted = line.startsWith('-') ? color.red(line) : line.startsWith('+') ? color.green(line) : line;
|
|
249
333
|
out.push(`│ ${painted}`);
|
|
@@ -253,7 +337,9 @@ function renderApproval(preview, color) {
|
|
|
253
337
|
const contentLines = String(preview.content ?? '').split('\n');
|
|
254
338
|
const shown = contentLines.slice(0, 40);
|
|
255
339
|
for (const line of shown) out.push(`│ ${line}`);
|
|
256
|
-
if (contentLines.length > shown.length)
|
|
340
|
+
if (contentLines.length > shown.length) {
|
|
341
|
+
out.push(pick(`│ …(预览截断,共 ${contentLines.length} 行)`, `│ ... (preview truncated, ${contentLines.length} lines in total)`));
|
|
342
|
+
}
|
|
257
343
|
}
|
|
258
344
|
if (preview.note) out.push(`│ ${color.yellow(preview.note)}`);
|
|
259
345
|
out.push(bot);
|
|
@@ -269,7 +355,7 @@ function question(rl, prompt) {
|
|
|
269
355
|
* 不引 TUI 库:readline 就够,多行只是多问几次。
|
|
270
356
|
*/
|
|
271
357
|
async function readInput(rl, color) {
|
|
272
|
-
const first = await question(rl, color.cyan('你> '));
|
|
358
|
+
const first = await question(rl, color.cyan(pick('你> ', 'you> ')));
|
|
273
359
|
if (!first.trimEnd().endsWith('\\')) return first;
|
|
274
360
|
const parts = [first.trimEnd().slice(0, -1)];
|
|
275
361
|
for (;;) {
|
|
@@ -292,14 +378,22 @@ function makeApproval({ autoApprove, rl, color }) {
|
|
|
292
378
|
return async (pending, preview) => {
|
|
293
379
|
renderApproval(preview, color);
|
|
294
380
|
if (autoApprove) {
|
|
295
|
-
process.stderr.write(color.yellow(' [--yes] 已自动批准这次写入\n'));
|
|
381
|
+
process.stderr.write(color.yellow(pick(' [--yes] 已自动批准这次写入\n', ' [--yes] write auto-approved\n')));
|
|
296
382
|
return true;
|
|
297
383
|
}
|
|
298
384
|
if (!rl || !process.stdin.isTTY) {
|
|
299
|
-
process.stderr.write(color.red(' 非交互环境,默认拒绝文件写入(确需自动批准请加 --yes;MCP 调用不受此限)\n'));
|
|
385
|
+
process.stderr.write(color.red(pick(' 非交互环境,默认拒绝文件写入(确需自动批准请加 --yes;MCP 调用不受此限)\n', ' non-interactive environment: file writes are denied by default (pass --yes to auto-approve; MCP calls are not affected)\n')));
|
|
300
386
|
return false;
|
|
301
387
|
}
|
|
302
|
-
const ans = await question(
|
|
388
|
+
const ans = await question(
|
|
389
|
+
rl,
|
|
390
|
+
color.cyan(
|
|
391
|
+
pick(
|
|
392
|
+
`批准 ${preview.shell ? '执行该命令' : `写入 ${preview.path}`} ?(y/N) `,
|
|
393
|
+
`Approve ${preview.shell ? 'running this command' : `writing ${preview.path}`}? (y/N) `,
|
|
394
|
+
),
|
|
395
|
+
),
|
|
396
|
+
);
|
|
303
397
|
return /^(y|yes)$/i.test(ans.trim());
|
|
304
398
|
};
|
|
305
399
|
}
|
|
@@ -385,6 +479,9 @@ async function main() {
|
|
|
385
479
|
// 「配置还没弄好」恰恰是用户最可能先跑它的场景;交给 parseArgs 还会把
|
|
386
480
|
// `set system --foo` 这类取值当成选项吃掉。
|
|
387
481
|
const raw = process.argv.slice(2);
|
|
482
|
+
// 语言要在**任何输出之前**定下来:setup / config 子命令走的是 parseArgs 之前的路径,
|
|
483
|
+
// 所以这里从原始 argv 里取一次 --lang(不重复 parseArgs,免得未知参数被报两遍)。
|
|
484
|
+
setLang(resolveLang({ lang: langFromArgv(raw) }, process.env));
|
|
388
485
|
// `gateway-agent setup`:首次配置引导。和 `config` 一样放在 parseArgs 之前 ——
|
|
389
486
|
// 「还没配」正是最可能先跑它的场景,而它自己收 --base-url / --key(给了就不问)。
|
|
390
487
|
if (raw[0] === 'setup') {
|
|
@@ -413,7 +510,7 @@ async function main() {
|
|
|
413
510
|
const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
|
|
414
511
|
|
|
415
512
|
if (args.help) {
|
|
416
|
-
console.log(
|
|
513
|
+
console.log(mainHelp());
|
|
417
514
|
return;
|
|
418
515
|
}
|
|
419
516
|
|
|
@@ -440,15 +537,27 @@ async function main() {
|
|
|
440
537
|
const maxSteps = numArg(args['max-steps']);
|
|
441
538
|
if (!key) {
|
|
442
539
|
process.stderr.write(
|
|
443
|
-
|
|
540
|
+
pick(
|
|
541
|
+
`[gateway] 还没配密钥 · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
|
|
542
|
+
`[gateway] no API key configured · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
|
|
543
|
+
) +
|
|
444
544
|
(srcInfo.allDefault && !srcInfo.configExists
|
|
445
|
-
?
|
|
446
|
-
|
|
545
|
+
? pick(
|
|
546
|
+
' 这台机器还没有任何配置(没找到 config.json / credentials.json / .env / 相关环境变量):\n' +
|
|
547
|
+
' 上面那两个值是**内置默认**,不是「已经配好了」——配一次之后就一直用它。\n',
|
|
548
|
+
' This machine has no configuration yet (no config.json / credentials.json / .env / env vars):\n' +
|
|
549
|
+
' the two values above are **built-in defaults**, not something you configured — set them once and they stick.\n',
|
|
550
|
+
)
|
|
447
551
|
: ''),
|
|
448
552
|
);
|
|
449
553
|
// 没密钥时最实用的出口是 Web UI:它没有密钥也能起(KEY_HINT 第①条没说那个页面怎么起起来)
|
|
450
|
-
process.stderr.write(
|
|
451
|
-
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
pick(
|
|
556
|
+
' Web UI(没密钥也能起):gateway-task → http://127.0.0.1:3100/task\n',
|
|
557
|
+
' Web UI (starts without a key): gateway-task -> http://127.0.0.1:3100/task\n',
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
process.stderr.write(`${pick('[错误]', '[error]')} ${keyHint()}\n`);
|
|
452
561
|
process.exit(1);
|
|
453
562
|
}
|
|
454
563
|
|
|
@@ -474,7 +583,7 @@ async function main() {
|
|
|
474
583
|
},
|
|
475
584
|
});
|
|
476
585
|
if (!r.ok) {
|
|
477
|
-
process.stderr.write(`[错误] ${r.error}\n`);
|
|
586
|
+
process.stderr.write(pick(`[错误] ${r.error}\n`, `[error] ${r.error}\n`));
|
|
478
587
|
process.exitCode = r.code || 1;
|
|
479
588
|
return;
|
|
480
589
|
}
|
|
@@ -495,7 +604,7 @@ async function main() {
|
|
|
495
604
|
sessionNotice = resolved.notice;
|
|
496
605
|
store = createSessionStore(sessionDir);
|
|
497
606
|
} catch (e) {
|
|
498
|
-
sessionWarning = `会话落盘不可用(${e.message}
|
|
607
|
+
sessionWarning = pick(`会话落盘不可用(${e.message}),本次会话不会保存`, `Session persistence unavailable (${e.message}); this session will not be saved`);
|
|
499
608
|
}
|
|
500
609
|
}
|
|
501
610
|
|
|
@@ -504,23 +613,23 @@ async function main() {
|
|
|
504
613
|
if (args.resume !== undefined) {
|
|
505
614
|
const id = typeof args.resume === 'string' ? args.resume.trim() : '';
|
|
506
615
|
if (!store) {
|
|
507
|
-
process.stderr.write('[错误] --resume 与 --no-session 不能同时用\n');
|
|
616
|
+
process.stderr.write(pick('[错误] --resume 与 --no-session 不能同时用\n', '[error] --resume and --no-session cannot be used together\n'));
|
|
508
617
|
process.exit(1);
|
|
509
618
|
}
|
|
510
619
|
if (!id) {
|
|
511
|
-
process.stderr.write(`[错误] --resume 需要一个会话 id(UUID)。会话目录:${sessionDir}\n`);
|
|
620
|
+
process.stderr.write(pick(`[错误] --resume 需要一个会话 id(UUID)。会话目录:${sessionDir}\n`, `[error] --resume needs a session id (UUID). Session directory: ${sessionDir}\n`));
|
|
512
621
|
process.exit(1);
|
|
513
622
|
}
|
|
514
623
|
restored = store.read(id);
|
|
515
624
|
if (!restored) {
|
|
516
|
-
process.stderr.write(`[错误] 找不到会话 ${id}(id 需为 UUID,且未被 TTL 清理)。会话目录:${sessionDir}\n`);
|
|
625
|
+
process.stderr.write(pick(`[错误] 找不到会话 ${id}(id 需为 UUID,且未被 TTL 清理)。会话目录:${sessionDir}\n`, `[error] session ${id} not found (id must be a UUID and not expired by TTL). Session directory: ${sessionDir}\n`));
|
|
517
626
|
process.exit(1);
|
|
518
627
|
}
|
|
519
628
|
} else if (args.continue) {
|
|
520
629
|
restored = store ? store.latest(SESSION_TTL_MS) : null;
|
|
521
630
|
if (!restored) {
|
|
522
631
|
const plain = makeColor(false);
|
|
523
|
-
process.stderr.write(`${plain.yellow('!')} --continue 没有找到可恢复的会话,将新建一个\n`);
|
|
632
|
+
process.stderr.write(pick(`${plain.yellow('!')} --continue 没有找到可恢复的会话,将新建一个\n`, `${plain.yellow('!')} --continue found no session to resume; starting a new one\n`));
|
|
524
633
|
}
|
|
525
634
|
}
|
|
526
635
|
|
|
@@ -529,7 +638,7 @@ async function main() {
|
|
|
529
638
|
const want = typeof args.cwd === 'string' ? args.cwd : restored?.workingDir || process.cwd();
|
|
530
639
|
workDir = checkWorkDir(want);
|
|
531
640
|
} catch (e) {
|
|
532
|
-
process.stderr.write(`[错误] 工作目录无效:${e.message}\n`);
|
|
641
|
+
process.stderr.write(pick(`[错误] 工作目录无效:${e.message}\n`, `[error] invalid working directory: ${e.message}\n`));
|
|
533
642
|
process.exit(1);
|
|
534
643
|
}
|
|
535
644
|
|
|
@@ -549,7 +658,7 @@ async function main() {
|
|
|
549
658
|
const color = makeColor(!args['no-color'] && !process.env.NO_COLOR && Boolean(process.stdout.isTTY));
|
|
550
659
|
const jsonOut = args['output-format'] === 'stream-json';
|
|
551
660
|
if (args['output-format'] && !jsonOut && args['output-format'] !== 'text') {
|
|
552
|
-
process.stderr.write('[错误] --output-format 只支持 text / stream-json\n');
|
|
661
|
+
process.stderr.write(pick('[错误] --output-format 只支持 text / stream-json\n', '[error] --output-format only supports text / stream-json\n'));
|
|
553
662
|
process.exit(1);
|
|
554
663
|
}
|
|
555
664
|
const renderer = createRenderer({ color, verbose: Boolean(args.verbose), json: jsonOut });
|
|
@@ -559,7 +668,7 @@ async function main() {
|
|
|
559
668
|
try {
|
|
560
669
|
store.write(session);
|
|
561
670
|
} catch (e) {
|
|
562
|
-
process.stderr.write(color.yellow(`! 会话落盘失败:${e.message}\n`));
|
|
671
|
+
process.stderr.write(color.yellow(pick(`! 会话落盘失败:${e.message}\n`, `! could not save the session: ${e.message}\n`)));
|
|
563
672
|
}
|
|
564
673
|
};
|
|
565
674
|
|
|
@@ -573,7 +682,7 @@ async function main() {
|
|
|
573
682
|
}
|
|
574
683
|
|
|
575
684
|
if (!interactive && !prompt) {
|
|
576
|
-
console.log(
|
|
685
|
+
console.log(mainHelp());
|
|
577
686
|
return;
|
|
578
687
|
}
|
|
579
688
|
|
|
@@ -594,7 +703,7 @@ async function main() {
|
|
|
594
703
|
let controller = null;
|
|
595
704
|
const onSigint = () => {
|
|
596
705
|
if (controller) {
|
|
597
|
-
process.stderr.write(`\n${color.yellow('!')} 已中断当前任务\n`);
|
|
706
|
+
process.stderr.write(pick(`\n${color.yellow('!')} 已中断当前任务\n`, `\n${color.yellow('!')} current task interrupted\n`));
|
|
598
707
|
controller.abort();
|
|
599
708
|
controller = null;
|
|
600
709
|
} else if (rl) {
|
|
@@ -622,25 +731,46 @@ async function main() {
|
|
|
622
731
|
// 注意后半句的措辞:`GATEWAY_MCP=off` 时工具是网关自己注入的,说「没有 MCP」会误导,
|
|
623
732
|
// 只能保证「CLI 不携带」。
|
|
624
733
|
const mcpLine = mcpInfo?.error
|
|
625
|
-
? `${mcpStatusText()} · ${
|
|
626
|
-
|
|
627
|
-
|
|
734
|
+
? `${mcpStatusText()} · ${
|
|
735
|
+
mcpInfo.attemptedAt
|
|
736
|
+
? pick(`已请求 GET ${baseUrl}/v1/mcp/tools`, `requested GET ${baseUrl}/v1/mcp/tools`)
|
|
737
|
+
: pick('按上述原因未向网关发出请求', 'no request sent to the gateway (see above)')
|
|
738
|
+
}${pick('(CLI 本次不携带 MCP 工具)', ' (this CLI run carries no MCP tools)')}`
|
|
628
739
|
: mcpStatusText();
|
|
629
740
|
process.stderr.write(
|
|
630
|
-
|
|
741
|
+
pick(
|
|
742
|
+
`[gateway] 原生 Agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
|
|
743
|
+
`[gateway] native agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
|
|
744
|
+
) +
|
|
631
745
|
// 密钥这一行上轮只做在 Web 横幅里,CLI 的横幅反倒不说密钥从哪来 —— 而「我没配过密钥,
|
|
632
746
|
// 它怎么跑起来了」正是最需要一句解释的情形(多半是当前目录的 .env 给的)
|
|
633
|
-
` 密钥 ${maskKey(key)}(${keySrcOf})\n` +
|
|
634
|
-
` 工作目录 ${workDir}\n` +
|
|
635
|
-
|
|
636
|
-
|
|
747
|
+
pick(` 密钥 ${maskKey(key)}(${keySrcOf})\n`, ` key ${maskKey(key)} (${keySrcOf})\n`) +
|
|
748
|
+
pick(` 工作目录 ${workDir}\n`, ` working dir ${workDir}\n`) +
|
|
749
|
+
pick(
|
|
750
|
+
` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n`,
|
|
751
|
+
` tools ${toolNames}${args['allow-bash'] ? ' [bash enabled]' : ''}\n`,
|
|
752
|
+
) +
|
|
753
|
+
pick(
|
|
754
|
+
' Web UI gateway-task → http://127.0.0.1:3100/task(本命令不会替你打开浏览器)\n',
|
|
755
|
+
' Web UI: gateway-task -> http://127.0.0.1:3100/task (this command never opens a browser)\n',
|
|
756
|
+
) +
|
|
637
757
|
` ${mcpLine}\n` +
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
758
|
+
pick(
|
|
759
|
+
` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n`,
|
|
760
|
+
` max steps ${session.maxSteps || AGENT_LIMITS.MAX_STEPS}\n`,
|
|
761
|
+
) +
|
|
762
|
+
(resumedFrom
|
|
763
|
+
? pick(` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n`, ` resumed session ${resumedFrom} (${countTurns(session)} messages)\n`)
|
|
764
|
+
: '') +
|
|
765
|
+
(store ? pick(` 会话目录 ${sessionDir}\n`, ` session dir ${sessionDir}\n`) : '') +
|
|
641
766
|
(sessionNotice ? `${color.yellow('!')} ${sessionNotice}\n` : '') +
|
|
642
767
|
(sessionWarning ? `${color.yellow('!')} ${sessionWarning}\n` : '') +
|
|
643
|
-
(memory
|
|
768
|
+
(memory
|
|
769
|
+
? pick(
|
|
770
|
+
` 已注入项目记忆(${MEMORY_FILES.join(' / ')})\n`,
|
|
771
|
+
` project memory injected (${MEMORY_FILES.join(' / ')})\n`,
|
|
772
|
+
)
|
|
773
|
+
: ''),
|
|
644
774
|
);
|
|
645
775
|
|
|
646
776
|
const runOnce = async (text) => {
|
|
@@ -653,17 +783,17 @@ async function main() {
|
|
|
653
783
|
}
|
|
654
784
|
persist();
|
|
655
785
|
const u = session.usage;
|
|
656
|
-
process.stderr.write(color.dim(`[usage] 输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens\n`));
|
|
786
|
+
process.stderr.write(color.dim(pick(`[usage] 输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens\n`, `[usage] prompt ${u.prompt} / completion ${u.completion} / total ${u.prompt + u.completion} tokens\n`)));
|
|
657
787
|
if (jsonOut) process.stdout.write(`${JSON.stringify({ type: 'done', sessionId: session.id, usage: { ...u } })}\n`);
|
|
658
788
|
};
|
|
659
789
|
|
|
660
790
|
// 会话里有待批准的写入(进程退出前留下的)→ 先问批不批再接着跑
|
|
661
791
|
if (session.pending) {
|
|
662
|
-
process.stderr.write(color.yellow(`! 恢复了一个待批准的写入:${session.pending.name}\n`));
|
|
792
|
+
process.stderr.write(color.yellow(pick(`! 恢复了一个待批准的写入:${session.pending.name}\n`, `! recovered a pending write: ${session.pending.name}\n`)));
|
|
663
793
|
controller = new AbortController();
|
|
664
794
|
try {
|
|
665
795
|
const outcome = await resumePendingTurn({ cfg, session, onEvent: renderer.handle, askApproval, persist, signal: controller.signal });
|
|
666
|
-
if (outcome) process.stderr.write(color.dim(`[恢复完成] 状态 ${outcome.status}\n`));
|
|
796
|
+
if (outcome) process.stderr.write(color.dim(pick(`[恢复完成] 状态 ${outcome.status}\n`, `[resume done] status ${outcome.status}\n`)));
|
|
667
797
|
} catch (e) {
|
|
668
798
|
fail(e, { exit: false });
|
|
669
799
|
} finally {
|
|
@@ -673,7 +803,7 @@ async function main() {
|
|
|
673
803
|
}
|
|
674
804
|
|
|
675
805
|
if (interactive) {
|
|
676
|
-
process.stderr.write(color.dim('交互模式:直接输入任务;/help 看命令,/exit 退出。\n'));
|
|
806
|
+
process.stderr.write(color.dim(pick('交互模式:直接输入任务;/help 看命令,/exit 退出。\n', 'Interactive mode: type a task; /help lists commands, /exit quits.\n')));
|
|
677
807
|
const ctx = {
|
|
678
808
|
get session() {
|
|
679
809
|
return session;
|
|
@@ -701,6 +831,10 @@ async function main() {
|
|
|
701
831
|
})),
|
|
702
832
|
// /config 用:与 `gateway-agent config` 完全同一份实现与同一个文件
|
|
703
833
|
configOpts: () => ({ env: process.env, file: settingsFilePath() }),
|
|
834
|
+
// 需要用户当场回答一个问题的命令(现在只有 /mcp add 的追问与 /mcp rm 的确认)。
|
|
835
|
+
// **非 TTY 返回 null**:那种环境里没人能回答,命令层据此改成「要求写成参数」,
|
|
836
|
+
// 而不是把 null 当默认值用——删服务器这种事猜错一次就没了。
|
|
837
|
+
ask: (prompt) => (rl && process.stdin.isTTY ? question(rl, prompt) : Promise.resolve(null)),
|
|
704
838
|
};
|
|
705
839
|
|
|
706
840
|
for (;;) {
|
|
@@ -717,7 +851,7 @@ async function main() {
|
|
|
717
851
|
const cmd = parseSlash(line);
|
|
718
852
|
if (cmd) {
|
|
719
853
|
if (cmd.error) {
|
|
720
|
-
process.stderr.write(color.red(` ${cmd.error}\n 用法:${cmd.cmd.usage}\n`));
|
|
854
|
+
process.stderr.write(color.red(pick(` ${cmd.error}\n 用法:${cmd.cmd.usage}\n`, ` ${cmd.error}\n usage: ${cmd.cmd.usage}\n`)));
|
|
721
855
|
continue;
|
|
722
856
|
}
|
|
723
857
|
// await 是必须的:命令可以是异步的(如 /mcp 要出网重拉清单)。
|
|
@@ -745,9 +879,12 @@ async function main() {
|
|
|
745
879
|
const near = suggest(head.slice(1));
|
|
746
880
|
if (near.length) {
|
|
747
881
|
process.stderr.write(color.dim(
|
|
748
|
-
|
|
882
|
+
pick(
|
|
883
|
+
` 命令名不完整:${head}\n 候选:${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`,
|
|
884
|
+
` incomplete command: ${head}\n candidates: ${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`,
|
|
885
|
+
)));
|
|
749
886
|
} else {
|
|
750
|
-
process.stderr.write(color.dim(`未知命令 ${head}(/help 看可用命令)。\n`));
|
|
887
|
+
process.stderr.write(color.dim(pick(`未知命令 ${head}(/help 看可用命令)。\n`, `unknown command ${head} (see /help for the list).\n`)));
|
|
751
888
|
}
|
|
752
889
|
continue;
|
|
753
890
|
}
|
|
@@ -762,7 +899,7 @@ async function main() {
|
|
|
762
899
|
rl.close();
|
|
763
900
|
saveHistory(sessionDir, rl);
|
|
764
901
|
}
|
|
765
|
-
process.stderr.write('再见。\n');
|
|
902
|
+
process.stderr.write(pick('再见。\n', 'Bye.\n'));
|
|
766
903
|
return;
|
|
767
904
|
}
|
|
768
905
|
|
|
@@ -778,6 +915,6 @@ async function main() {
|
|
|
778
915
|
}
|
|
779
916
|
|
|
780
917
|
main().catch((e) => {
|
|
781
|
-
process.stderr.write(`[错误] ${e?.message || e}\n`);
|
|
918
|
+
process.stderr.write(pick(`[错误] ${e?.message || e}\n`, `[error] ${e?.message || e}\n`));
|
|
782
919
|
process.exit(1);
|
|
783
920
|
});
|
package/cli-anthropic.js
CHANGED
|
@@ -27,7 +27,9 @@ import readline from 'node:readline';
|
|
|
27
27
|
import { randomUUID } from 'node:crypto';
|
|
28
28
|
|
|
29
29
|
import { loadDotEnv, parseArgs, sessionBaggage, DEFAULT_BASE_URL } from './lib/common.js';
|
|
30
|
-
import { resolveConfig, fail,
|
|
30
|
+
import { resolveConfig, fail, keyHint } from './lib/config.js';
|
|
31
|
+
// CLI 侧语言(只覆盖命令行输出;Web 界面与手册页仍是中文)。默认 zh,见 lib/i18n.js
|
|
32
|
+
import { pick, setLang, resolveLang, LANG_FLAG } from './lib/i18n.js';
|
|
31
33
|
|
|
32
34
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
33
35
|
loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参与默认值计算
|
|
@@ -35,7 +37,7 @@ loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参
|
|
|
35
37
|
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
36
38
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
37
39
|
|
|
38
|
-
const
|
|
40
|
+
const HELP_ZH = `用法:node cli-anthropic.js [选项] [提示词]
|
|
39
41
|
|
|
40
42
|
选项:
|
|
41
43
|
-p, --prompt <文本> 单轮提问内容(省略则读位置参数 / 标准输入)
|
|
@@ -48,9 +50,32 @@ const HELP = `用法:node cli-anthropic.js [选项] [提示词]
|
|
|
48
50
|
--no-stream 关闭流式输出(默认开启)
|
|
49
51
|
--json 输出完整原始响应(非流式)
|
|
50
52
|
-i, --interactive 进入多轮交互(REPL)
|
|
53
|
+
--lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)
|
|
51
54
|
-h, --help 显示帮助
|
|
52
55
|
`;
|
|
53
56
|
|
|
57
|
+
const HELP_EN = `Usage: node cli-anthropic.js [options] [prompt]
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
-p, --prompt <text> one-shot prompt (falls back to the positional argument / stdin)
|
|
61
|
+
-s, --system <text> system prompt
|
|
62
|
+
-m, --model <model> model name (default ${DEFAULT_MODEL})
|
|
63
|
+
-k, --key <sk-key> gateway API key (or env SK/GATEWAY_KEY/ANTHROPIC_API_KEY, or .env)
|
|
64
|
+
--base-url <url> gateway base URL (default ${DEFAULT_BASE_URL})
|
|
65
|
+
--max-tokens <v> max generated tokens (required by Anthropic, default ${DEFAULT_MAX_TOKENS})
|
|
66
|
+
--temperature <v> sampling temperature
|
|
67
|
+
--no-stream disable streaming output (on by default)
|
|
68
|
+
--json print the full raw response (non-streaming)
|
|
69
|
+
-i, --interactive multi-turn interactive mode (REPL)
|
|
70
|
+
--lang <zh|en> output language (default zh; env LLM_GATEWAY_LANG)
|
|
71
|
+
-h, --help show this help
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
/** 大段帮助取词必须在**调用时**(模块顶层调 pick 会早于 setLang,永远拿到中文) */
|
|
75
|
+
function help() {
|
|
76
|
+
return pick(HELP_ZH, HELP_EN);
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
// 值型选项:会消费下一个参数作为值;布尔型选项:不消费后续参数
|
|
55
80
|
const VALUE_FLAGS = {
|
|
56
81
|
'--prompt': 'prompt', '-p': 'prompt',
|
|
@@ -60,6 +85,7 @@ const VALUE_FLAGS = {
|
|
|
60
85
|
'--base-url': 'base-url',
|
|
61
86
|
'--temperature': 'temperature',
|
|
62
87
|
'--max-tokens': 'max-tokens',
|
|
88
|
+
...LANG_FLAG, // --lang <zh|en>:与另外三个入口同一份定义(lib/i18n.js)
|
|
63
89
|
};
|
|
64
90
|
const BOOL_FLAGS = {
|
|
65
91
|
'--no-stream': 'no-stream',
|
|
@@ -79,13 +105,23 @@ function readStdin() {
|
|
|
79
105
|
});
|
|
80
106
|
}
|
|
81
107
|
|
|
108
|
+
/** usage 行:Anthropic 的字段名与 OpenAI 不同,这里统一成一句按当前语言取词 */
|
|
109
|
+
function usageLine(input, output) {
|
|
110
|
+
return pick(
|
|
111
|
+
`[usage] 输入 ${input ?? '?'} / 输出 ${output ?? '?'} tokens`,
|
|
112
|
+
`[usage] input ${input ?? '?'} / output ${output ?? '?'} tokens`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
// ---------- 主流程 ----------
|
|
83
117
|
|
|
84
118
|
async function main() {
|
|
85
119
|
const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
|
|
120
|
+
// 语言要在**任何输出之前**定下来:--help / 报错 / 横幅都按它取词
|
|
121
|
+
setLang(resolveLang(args, process.env));
|
|
86
122
|
|
|
87
123
|
if (args.help) {
|
|
88
|
-
console.log(
|
|
124
|
+
console.log(help());
|
|
89
125
|
return;
|
|
90
126
|
}
|
|
91
127
|
|
|
@@ -95,14 +131,19 @@ async function main() {
|
|
|
95
131
|
});
|
|
96
132
|
|
|
97
133
|
if (!key) {
|
|
98
|
-
console.error(
|
|
134
|
+
console.error(`${pick('[错误]', '[error]')} ${keyHint()}`);
|
|
99
135
|
process.exit(1);
|
|
100
136
|
}
|
|
101
137
|
|
|
102
138
|
// 一次进程运行 = 一次对话。用 W3C baggage 告诉网关,好让它把同一会话的请求聚成一条。
|
|
103
139
|
const sessionId = randomUUID();
|
|
104
140
|
const baggage = sessionBaggage(sessionId);
|
|
105
|
-
if (baggage)
|
|
141
|
+
if (baggage) {
|
|
142
|
+
process.stderr.write(pick(
|
|
143
|
+
`[gateway] 会话 ${sessionId}(W3C baggage: session.id)\n`,
|
|
144
|
+
`[gateway] session ${sessionId} (W3C baggage: session.id)\n`,
|
|
145
|
+
));
|
|
146
|
+
}
|
|
106
147
|
|
|
107
148
|
const client = new Anthropic({
|
|
108
149
|
baseURL: baseUrl,
|
|
@@ -120,7 +161,7 @@ async function main() {
|
|
|
120
161
|
if (!prompt && args._.length) prompt = args._.join(' ');
|
|
121
162
|
if (!prompt) prompt = await readStdin();
|
|
122
163
|
if (!prompt) {
|
|
123
|
-
console.log(
|
|
164
|
+
console.log(help());
|
|
124
165
|
return;
|
|
125
166
|
}
|
|
126
167
|
|
|
@@ -166,7 +207,7 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
|
|
|
166
207
|
process.stdout.write('\n');
|
|
167
208
|
// 跨方言(Anthropic 入口 → OpenAI 上游)流式时网关不逐块回传 usage,仅在确有输出 token 时才展示
|
|
168
209
|
if (outputTokens !== null) {
|
|
169
|
-
process.stderr.write(
|
|
210
|
+
process.stderr.write(`${usageLine(inputTokens, outputTokens)}\n`);
|
|
170
211
|
}
|
|
171
212
|
} else {
|
|
172
213
|
const resp = await client.messages.create(req);
|
|
@@ -174,12 +215,12 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
|
|
|
174
215
|
console.log(JSON.stringify(resp, null, 2));
|
|
175
216
|
} else {
|
|
176
217
|
const text = (resp.content || [])
|
|
177
|
-
.map((b) => (b.type === 'text' ? b.text : b.type === 'tool_use' ? `[工具调用 ${b.name}]` : ''))
|
|
218
|
+
.map((b) => (b.type === 'text' ? b.text : b.type === 'tool_use' ? pick(`[工具调用 ${b.name}]`, `[tool call ${b.name}]`) : ''))
|
|
178
219
|
.join('');
|
|
179
220
|
console.log(text);
|
|
180
221
|
}
|
|
181
222
|
const u = resp.usage;
|
|
182
|
-
if (u) process.stderr.write(
|
|
223
|
+
if (u) process.stderr.write(`${usageLine(u.input_tokens, u.output_tokens)}\n`);
|
|
183
224
|
}
|
|
184
225
|
} catch (e) {
|
|
185
226
|
fail(e, { exit: exitOnError });
|
|
@@ -187,10 +228,13 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
|
|
|
187
228
|
}
|
|
188
229
|
|
|
189
230
|
async function repl(client, model, opts) {
|
|
190
|
-
console.error(
|
|
231
|
+
console.error(pick(
|
|
232
|
+
`[gateway] 交互模式 model=${model},输入文本提问,\`exit\` 退出`,
|
|
233
|
+
`[gateway] interactive mode model=${model}; type a prompt, \`exit\` to quit`,
|
|
234
|
+
));
|
|
191
235
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
192
236
|
const messages = [];
|
|
193
|
-
const ask = () => new Promise((resolve) => rl.question('你> ', resolve));
|
|
237
|
+
const ask = () => new Promise((resolve) => rl.question(pick('你> ', 'you> '), resolve));
|
|
194
238
|
for (;;) {
|
|
195
239
|
let line;
|
|
196
240
|
try {
|
|
@@ -203,7 +247,7 @@ async function repl(client, model, opts) {
|
|
|
203
247
|
messages.push({ role: 'user', content: line });
|
|
204
248
|
|
|
205
249
|
// 复用底层流式,逐字打印并把助手回复累积进上下文
|
|
206
|
-
process.stdout.write('助手> ');
|
|
250
|
+
process.stdout.write(pick('助手> ', 'assistant> '));
|
|
207
251
|
const req = { model, max_tokens: opts.maxTokens, messages, stream: true, temperature: opts.temperature };
|
|
208
252
|
try {
|
|
209
253
|
let out = '';
|
|
@@ -221,7 +265,7 @@ async function repl(client, model, opts) {
|
|
|
221
265
|
}
|
|
222
266
|
process.stdout.write('\n');
|
|
223
267
|
if (outputTokens !== null) {
|
|
224
|
-
process.stderr.write(
|
|
268
|
+
process.stderr.write(`${usageLine(inputTokens, outputTokens)}\n`);
|
|
225
269
|
}
|
|
226
270
|
messages.push({ role: 'assistant', content: out });
|
|
227
271
|
} catch (e) {
|