llm-api-gateway-cli 1.0.1 → 1.0.3
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.md +3 -1
- package/cli-agent.js +108 -8
- package/lib/config.js +20 -7
- package/lib/hub.js +7 -3
- package/lib/launcher.js +141 -0
- package/lib/setup.js +260 -0
- package/package.json +1 -1
- package/public/app.js +51 -0
- package/public/index.html +8 -0
- package/public/manual.html +32 -4
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# LLM API Gateway CLI
|
|
1
|
+
# LLM API Gateway CLI 编程工具
|
|
2
2
|
|
|
3
3
|
验证 `llm-api-gateway`(`http://127.0.0.1:9000`)除了 Web 对话页 / SDK 之外,**CLI 方式同样可用**。多个脚本覆盖多种接入方言,其中**原生 Agent(`cli-agent.js`)是终端里干活的推荐方式** —— 自带工具循环,无需安装 Claude Code。
|
|
4
4
|
|
|
@@ -41,6 +41,8 @@ npm install # 安装 openai 与 @anthropic-ai/sdk
|
|
|
41
41
|
|
|
42
42
|
> 不想翻文档也行:起服务后打开 **[操作手册页 `/manual`](http://127.0.0.1:3100/manual)**,第一节就是「安装与启动」—— 三条路线、参数表、换镜像、**首次配置(界面 / 命令行 / 环境变量三种给密钥方式、密钥存哪、网关地址与模型)**、启动自检与卸载,命令都是可复制的。
|
|
43
43
|
|
|
44
|
+
> **装完第一步是 `gateway-agent setup`**:它会问一次**网关地址**与**网关密钥**并写进本机(`setup --base-url … --key …` 可一步到位;全新机器首次运行会自动进入这一步)。之后**裸敲 `llm-api-gateway-cli`(或 `gateway-agent`)就是启动器**:先问「① 对话 / ② 任务」,再问「① 命令行窗口 / ② 网页 UI」,选完直接进入对应形态 —— 不替你默认。选了网页 UI 才会起服务,然后**自己**打开 http://127.0.0.1:3100/task(「设置 → 其他配置」里地址与密钥都能改)。想跳过启动器:`-p "问题"` / `-i` / `gateway-task`。卸载用 `npm uninstall -g llm-api-gateway-cli`(`-g` 不能少)。
|
|
45
|
+
|
|
44
46
|
### macOS / Linux / WSL
|
|
45
47
|
|
|
46
48
|
```bash
|
package/cli-agent.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import readline from 'node:readline';
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
26
27
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
28
|
|
|
28
29
|
import { loadDotEnv, parseArgs, DEFAULT_BASE_URL, maskKey } from './lib/common.js';
|
|
@@ -32,6 +33,8 @@ import { createSession, restoreSession, runTurn, resumePendingTurn, countTurns }
|
|
|
32
33
|
import { createSessionStore, resolveSessionDir, SESSION_TTL_MS } from './lib/sessionstore.js';
|
|
33
34
|
import { COMMANDS, parseSlash, unescapeSlash, suggest, helpText, diffLines } from './lib/commands.js';
|
|
34
35
|
import { configCommand, configHelp } from './lib/configcmd.js';
|
|
36
|
+
import { runSetup, isFreshMachine, createTerminalAsk, setupHelp } from './lib/setup.js';
|
|
37
|
+
import { runLauncher } from './lib/launcher.js';
|
|
35
38
|
import { settingsFilePath, summarizeConfigSources } from './lib/settings.js';
|
|
36
39
|
import { secretSourceText } from './lib/secrets.js';
|
|
37
40
|
import { AGENT_LIMITS } from './lib/agent.js';
|
|
@@ -44,21 +47,31 @@ loadDotEnv(SCRIPT_DIR);
|
|
|
44
47
|
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
45
48
|
const HISTORY_LIMIT = 200;
|
|
46
49
|
|
|
47
|
-
const HELP = `用法:
|
|
48
|
-
|
|
50
|
+
const HELP = `用法:gateway-agent 启动器:先问「对话 / 任务」,再问「命令行 / 网页」
|
|
51
|
+
gateway-agent [选项] [提示词]
|
|
52
|
+
gateway-agent setup [--base-url <地址>] [--key sk-xxx] 首次配置引导
|
|
53
|
+
gateway-agent config <list|get|set|unset|path> [键] [值]
|
|
54
|
+
(同一入口的别名:llm-api-gateway-cli;源码目录里也可以 node cli-agent.js …)
|
|
49
55
|
|
|
50
56
|
模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
|
|
51
57
|
不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
|
|
52
58
|
|
|
59
|
+
相关命令(本机 Web UI —— 本命令不会替你打开浏览器):
|
|
60
|
+
gateway-task [--port 3100] 起本机 Web UI 与任务页(gateway-web 是同一服务的别名);
|
|
61
|
+
没有密钥也能起,网关地址与密钥在任务页「设置」里配
|
|
62
|
+
http://127.0.0.1:3100/ 聊天页
|
|
63
|
+
http://127.0.0.1:3100/task 任务页(「设置」里可改网关地址 / 模型 / 密钥)
|
|
64
|
+
http://127.0.0.1:3100/manual 操作手册页(安装、首次配置、指令一览的完整版)
|
|
65
|
+
|
|
53
66
|
子命令:
|
|
67
|
+
setup 首次配置引导:问一次「网关地址」与「网关密钥」并写进本机配置
|
|
68
|
+
(地址 → config.json,密钥 → credentials.json)。带 --base-url /
|
|
69
|
+
--key 时直接写入、不再提问;全新机器首次运行会自动进入这一步。
|
|
54
70
|
config list|get|set|unset|path
|
|
55
71
|
查看/修改持久化配置(存到磁盘,清缓存不丢)。config list 会标出
|
|
56
72
|
每一项「当前生效值」来自命令行、环境变量、配置文件还是内置默认
|
|
57
73
|
—— 改了没生效时看这一列就知道被谁盖住了。
|
|
58
74
|
|
|
59
|
-
模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
|
|
60
|
-
不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
|
|
61
|
-
|
|
62
75
|
选项:
|
|
63
76
|
-p, --prompt <文本> 单轮任务(省略则读位置参数 / 标准输入)
|
|
64
77
|
-i, --interactive 进入多轮交互(REPL)
|
|
@@ -337,11 +350,53 @@ function saveHistory(sessionDir, rl) {
|
|
|
337
350
|
|
|
338
351
|
// ---------- 主流程 ----------
|
|
339
352
|
|
|
353
|
+
/**
|
|
354
|
+
* 跑一次设置向导(`setup` 子命令与「首次运行自动引导」共用)。
|
|
355
|
+
* 只负责接线:问答用真终端,正文走 stdout,失败写 stderr。
|
|
356
|
+
*/
|
|
357
|
+
async function runSetupFlow(args = {}) {
|
|
358
|
+
const r = await runSetup({
|
|
359
|
+
ask: createTerminalAsk(),
|
|
360
|
+
args,
|
|
361
|
+
env: process.env,
|
|
362
|
+
log: (s) => process.stdout.write(s),
|
|
363
|
+
});
|
|
364
|
+
if (!r.ok) process.stderr.write(`[错误] ${r.error}\n`);
|
|
365
|
+
return r;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 尽力打开浏览器。失败绝不影响服务本身(URL 已经打印出来了) */
|
|
369
|
+
function openBrowser(url) {
|
|
370
|
+
try {
|
|
371
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
372
|
+
const argv = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
373
|
+
const child = spawn(cmd, argv, { stdio: 'ignore', detached: true });
|
|
374
|
+
// 不监听 error 的话,spawn 失败会变成未处理事件把进程炸掉 —— 打不开浏览器不值得让服务挂掉
|
|
375
|
+
child.on('error', () => {});
|
|
376
|
+
child.unref();
|
|
377
|
+
return true;
|
|
378
|
+
} catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
340
383
|
async function main() {
|
|
341
384
|
// `gateway-agent config …`:配置子命令。放在 parseArgs 之前,而且不需要密钥 ——
|
|
342
385
|
// 「配置还没弄好」恰恰是用户最可能先跑它的场景;交给 parseArgs 还会把
|
|
343
386
|
// `set system --foo` 这类取值当成选项吃掉。
|
|
344
387
|
const raw = process.argv.slice(2);
|
|
388
|
+
// `gateway-agent setup`:首次配置引导。和 `config` 一样放在 parseArgs 之前 ——
|
|
389
|
+
// 「还没配」正是最可能先跑它的场景,而它自己收 --base-url / --key(给了就不问)。
|
|
390
|
+
if (raw[0] === 'setup') {
|
|
391
|
+
const rest = raw.slice(1);
|
|
392
|
+
if (rest.includes('-h') || rest.includes('--help')) {
|
|
393
|
+
process.stdout.write(setupHelp());
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const r = await runSetupFlow(parseArgs(rest, VALUE_FLAGS, BOOL_FLAGS));
|
|
397
|
+
if (r.code) process.exitCode = r.code;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
345
400
|
if (raw[0] === 'config') {
|
|
346
401
|
const rest = raw.slice(1);
|
|
347
402
|
if (rest.includes('-h') || rest.includes('--help')) {
|
|
@@ -365,7 +420,16 @@ async function main() {
|
|
|
365
420
|
// bash 默认关闭:命令级权限不该默默打开
|
|
366
421
|
setBashEnabled(Boolean(args['allow-bash']));
|
|
367
422
|
|
|
368
|
-
|
|
423
|
+
let bootCfg = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
|
|
424
|
+
|
|
425
|
+
// 首次配置引导:这台机器一条配置来源都没有 + 交互终端 → 先把「网关地址 / 密钥」问清楚,
|
|
426
|
+
// 配完就地继续启动。不这么做的话,第一次用的人只会收到一句「缺少 sk- 密钥」然后被赶去看文档。
|
|
427
|
+
// 非 TTY(管道 / CI)不触发,保持原来的报错退出语义。
|
|
428
|
+
if (!bootCfg.key && process.stdin.isTTY && isFreshMachine({ env: process.env })) {
|
|
429
|
+
const r = await runSetupFlow(args);
|
|
430
|
+
if (r.ok) bootCfg = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
|
|
431
|
+
}
|
|
432
|
+
|
|
369
433
|
const { key, baseUrl, model, temperature, maxTokens } = bootCfg;
|
|
370
434
|
// 启动横幅必须能回答「这个值从哪来」:用户在新机器上第一次跑,看到 base/model 有值会以为
|
|
371
435
|
// 「已经配好了」,其实那可能只是**内置默认值**,也可能来自**当前目录的 .env**(别的项目的配置)。
|
|
@@ -382,10 +446,42 @@ async function main() {
|
|
|
382
446
|
' 上面那两个值是**内置默认**,不是「已经配好了」——配一次之后就一直用它。\n'
|
|
383
447
|
: ''),
|
|
384
448
|
);
|
|
449
|
+
// 没密钥时最实用的出口是 Web UI:它没有密钥也能起(KEY_HINT 第①条没说那个页面怎么起起来)
|
|
450
|
+
process.stderr.write(' Web UI(没密钥也能起):gateway-task → http://127.0.0.1:3100/task\n');
|
|
385
451
|
process.stderr.write(`[错误] ${KEY_HINT}\n`);
|
|
386
452
|
process.exit(1);
|
|
387
453
|
}
|
|
388
454
|
|
|
455
|
+
// 裸命令(一个参数都没有)+ 交互终端 → 启动器:先问「对话 / 任务」,再问「命令行 / 网页」。
|
|
456
|
+
// 只在 argv 恰好没有参数时触发,所以 -p(单轮)、-i(直接进对话)、管道输入三条老路径一点没变。
|
|
457
|
+
let launcherInteractive = false;
|
|
458
|
+
if (process.argv.length === 2 && process.stdin.isTTY) {
|
|
459
|
+
const r = await runLauncher({
|
|
460
|
+
ask: createTerminalAsk(),
|
|
461
|
+
log: (s) => process.stdout.write(s),
|
|
462
|
+
startCli: async (plan) => {
|
|
463
|
+
launcherInteractive = true;
|
|
464
|
+
// 任务态要指定在哪个目录干活:走 args.cwd,下面 checkWorkDir 会照常校验它
|
|
465
|
+
if (plan.workDir) args.cwd = plan.workDir;
|
|
466
|
+
return { code: 0 };
|
|
467
|
+
},
|
|
468
|
+
startWeb: async (plan) => {
|
|
469
|
+
// 不另起子进程:在本进程内起同一个 hub(聊天页/任务页是同一个服务),Ctrl+C 停止
|
|
470
|
+
const { runHub } = await import('./lib/hub.js');
|
|
471
|
+
runHub([], { entry: plan.entry });
|
|
472
|
+
openBrowser(plan.url);
|
|
473
|
+
return { code: 0 };
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
if (!r.ok) {
|
|
477
|
+
process.stderr.write(`[错误] ${r.error}\n`);
|
|
478
|
+
process.exitCode = r.code || 1;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// 选了网页:服务已经 listen,本进程就留着当服务用,不再往下走 REPL
|
|
482
|
+
if (r.plan?.kind === 'web') return;
|
|
483
|
+
}
|
|
484
|
+
|
|
389
485
|
// 会话存储(可关):--no-session 时 store 为 null,行为退回纯内存
|
|
390
486
|
let store = null;
|
|
391
487
|
let sessionDir = null;
|
|
@@ -467,7 +563,7 @@ async function main() {
|
|
|
467
563
|
}
|
|
468
564
|
};
|
|
469
565
|
|
|
470
|
-
let interactive = Boolean(args.interactive);
|
|
566
|
+
let interactive = Boolean(args.interactive) || launcherInteractive;
|
|
471
567
|
let prompt = typeof args.prompt === 'string' ? args.prompt : args._.join(' ').trim();
|
|
472
568
|
|
|
473
569
|
// 没给提示词又不在交互模式:先尝试标准输入;TTY 下(没有输入)则退化为交互模式
|
|
@@ -537,6 +633,7 @@ async function main() {
|
|
|
537
633
|
` 密钥 ${maskKey(key)}(${keySrcOf})\n` +
|
|
538
634
|
` 工作目录 ${workDir}\n` +
|
|
539
635
|
` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n` +
|
|
636
|
+
` Web UI gateway-task → http://127.0.0.1:3100/task(本命令不会替你打开浏览器)\n` +
|
|
540
637
|
` ${mcpLine}\n` +
|
|
541
638
|
` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n` +
|
|
542
639
|
(resumedFrom ? ` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n` : '') +
|
|
@@ -680,4 +777,7 @@ async function main() {
|
|
|
680
777
|
}
|
|
681
778
|
}
|
|
682
779
|
|
|
683
|
-
main()
|
|
780
|
+
main().catch((e) => {
|
|
781
|
+
process.stderr.write(`[错误] ${e?.message || e}\n`);
|
|
782
|
+
process.exit(1);
|
|
783
|
+
});
|
package/lib/config.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* 所有 CLI 都走 resolveConfig,保证问「密钥从哪来」只有一种答案。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { DEFAULT_BASE_URL, strArg } from './common.js';
|
|
12
12
|
import { resolveSecretKey } from './secrets.js';
|
|
13
|
+
import { resolveSettings, settingsFilePath } from './settings.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* 缺密钥时的统一提示(各 CLI 文案一致,避免用户按提示改了还是不通)。
|
|
@@ -19,8 +20,8 @@ import { resolveSecretKey } from './secrets.js';
|
|
|
19
20
|
* 用户会按着提示配完发现还是报缺密钥。
|
|
20
21
|
*/
|
|
21
22
|
export const KEY_HINT =
|
|
22
|
-
'缺少 sk- 密钥:①
|
|
23
|
-
+ '②
|
|
23
|
+
'缺少 sk- 密钥:① 执行 gateway-agent setup(首次配置引导:问一次网关地址与密钥,直接可用);'
|
|
24
|
+
+ '② 或 gateway-agent config set key sk-xxx / 在任务页·聊天页「设置」里填(都存 credentials.json,仅本用户可读);'
|
|
24
25
|
+ '③ 或 --key sk-xxx、环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY,或在 .env 中写 GATEWAY_KEY=sk-xxx';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -44,12 +45,21 @@ export function numArg(v) {
|
|
|
44
45
|
* @returns {{key:string, baseUrl:string, model:string, workDir:string,
|
|
45
46
|
* temperature:number|undefined, maxTokens:number|undefined, system:string|undefined}}
|
|
46
47
|
*/
|
|
47
|
-
export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env } = {}) {
|
|
48
|
+
export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env, file = settingsFilePath(env) } = {}) {
|
|
48
49
|
const maxTokens = numArg(args['max-tokens']) ?? defaultMaxTokens;
|
|
49
50
|
// 密钥走 lib/secrets.js:`--key` > 环境变量 / `.env` > credentials.json。
|
|
50
51
|
// 命令行必须认那个文件,否则「在界面里配一次就能用」对 CLI 入口是句空话
|
|
51
52
|
// (界面与 config set key 写的都是它)。来源字段一并带出来,横幅才能说清「谁给的」。
|
|
52
53
|
const secret = resolveSecretKey({ args, env });
|
|
54
|
+
// 网关地址与模型走 lib/settings.js 的**四层**优先级:--flag > 环境变量/.env > config.json > 内置默认。
|
|
55
|
+
//
|
|
56
|
+
// 这一段曾经用的是 lib/common.js 的 resolveBaseUrl —— 它只认 flag 与环境变量,**不读 config.json**。
|
|
57
|
+
// 后果是 `gateway-agent setup` 与 `config set baseUrl` 写进配置文件的地址,命令行根本不认:
|
|
58
|
+
// 网页/设置面板显示 192.168.x.x,命令行却仍旧连内置默认的 127.0.0.1:9000(模型同理)。
|
|
59
|
+
// 网页侧一直走 resolveSettings,所以「页面是对的、命令行是错的」——两条链必须合成一条。
|
|
60
|
+
const settings = resolveSettings({ file, env, args });
|
|
61
|
+
// 模型:配置文件里**真写了**才用它,否则保留各入口自己的 defaultModel(cli-anthropic 等默认值不同)
|
|
62
|
+
const modelFromFile = settings.sources.model === 'default' ? '' : strArg(settings.values.model);
|
|
53
63
|
return {
|
|
54
64
|
key: secret.key,
|
|
55
65
|
keySource: secret.source,
|
|
@@ -57,9 +67,12 @@ export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens,
|
|
|
57
67
|
// 整个来源对象一并带出:横幅要 `secretSourceText(cfg.secret)` 才能说清
|
|
58
68
|
// 「环境变量 GATEWAY_KEY(来自 .env:…)」这类细节
|
|
59
69
|
secret,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
// 与 resolveBaseUrl / Web 侧同一口径:去掉尾部斜杠
|
|
71
|
+
baseUrl: String(settings.values.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
72
|
+
// 顺序:--model > GATEWAY_MODEL > config.json > 该入口的默认模型
|
|
73
|
+
model: strArg(args.model) || strArg(env.GATEWAY_MODEL) || modelFromFile || defaultModel,
|
|
74
|
+
// 整份解析结果:调用方要 `settingSourceText(settings, 'baseUrl')` 这类「谁给的」信息
|
|
75
|
+
settings,
|
|
63
76
|
workDir: strArg(args.cwd) || strArg(args['work-dir']) || '',
|
|
64
77
|
temperature: numArg(args.temperature),
|
|
65
78
|
maxTokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined,
|
package/lib/hub.js
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
parseArgs,
|
|
23
23
|
strArg,
|
|
24
24
|
resolveKey,
|
|
25
|
-
resolveBaseUrl,
|
|
26
25
|
resolvePort,
|
|
27
26
|
sendJson,
|
|
28
27
|
readBody,
|
|
@@ -1345,8 +1344,13 @@ export function runHub(argv, { entry = 'web' } = {}) {
|
|
|
1345
1344
|
const settingsFile = settingsFilePath();
|
|
1346
1345
|
const settings = resolveSettings({ file: settingsFile, env: process.env, args });
|
|
1347
1346
|
for (const w of settings.warnings) console.warn(`[警告] ${w}`);
|
|
1348
|
-
|
|
1349
|
-
|
|
1347
|
+
// 网关地址与模型:事实来源是 lib/settings.js 的四层优先级(flag > 环境变量/.env > config.json > 内置默认)。
|
|
1348
|
+
// 这里曾经用 resolveBaseUrl(只认 flag/env),于是**横幅把 config.json 里的地址显示成内置默认**,
|
|
1349
|
+
// 而 applySettings 之后的运行态用的却是配置文件里的值 —— 横幅与事实打架,用户以为配置没生效。
|
|
1350
|
+
// 现在两边同源:横幅打印的就是运行态那个值。
|
|
1351
|
+
const baseUrl = String(settings.values.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
1352
|
+
const modelFromFile = settings.sources.model === 'default' ? '' : strArg(settings.values.model);
|
|
1353
|
+
const model = strArg(args.model) || strArg(process.env.GATEWAY_MODEL) || modelFromFile || DEFAULT_MODEL;
|
|
1350
1354
|
// 老的两个服务各用各的环境变量名,这里都认
|
|
1351
1355
|
const port =
|
|
1352
1356
|
resolvePort(args, process.env, 'WEB_PORT', null) ??
|
package/lib/launcher.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 裸命令启动器(`llm-api-gateway-cli` 不带参数时)
|
|
3
|
+
*
|
|
4
|
+
* 用户的要求很直接:**每次敲这个命令,应该由它来问「你要对话还是任务、要在命令行还是网页」**,
|
|
5
|
+
* 而不是默默替他选一个(今天的实现是「无参数 + TTY → 直接进 REPL」)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么拆成「纯映射 + 注入动作」两层:
|
|
8
|
+
* · `resolvePlan()` 是纯函数,四条路径的映射可以被测试逐条钉住;
|
|
9
|
+
* · `runLauncher()` 只管问与打印,真去起服务还是进 REPL 由调用方注入 ——
|
|
10
|
+
* 测试里换成假的,就永远不会在测试机上偷偷起一个 3100。
|
|
11
|
+
*
|
|
12
|
+
* 兼容性红线:**只有「无参数 + 交互终端」才进这里**。`-p`(单轮)、`-i`(直接进对话)、
|
|
13
|
+
* 管道输入(`echo … | llm-api-gateway-cli`)三条老路径一律不弹菜单。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { strArg } from './common.js';
|
|
17
|
+
|
|
18
|
+
/** 本机 Web UI 的默认端口。事实来源是 `lib/hub.js` 的 `DEFAULT_PORT`,测试会交叉断言两者一致 */
|
|
19
|
+
export const WEB_PORT = 3100;
|
|
20
|
+
|
|
21
|
+
export const TOPICS = [
|
|
22
|
+
{ value: 'chat', label: '对话', desc: '纯聊天,不碰你的文件' },
|
|
23
|
+
{ value: 'task', label: '任务', desc: '在指定目录里真读写文件,每次写入前先问你' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const SURFACES = [
|
|
27
|
+
{ value: 'cli', label: '命令行窗口', desc: '就在这个终端里继续' },
|
|
28
|
+
{ value: 'web', label: '网页 UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天页 / 任务页)` },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 纯函数:两个选择 → 要执行的动作。
|
|
33
|
+
*
|
|
34
|
+
* | topic | surface | 结果 |
|
|
35
|
+
* | --- | --- | --- |
|
|
36
|
+
* | chat | cli | 命令行对话(REPL) |
|
|
37
|
+
* | task | cli | 先问工作目录,再进任务态(REPL,写入需批准) |
|
|
38
|
+
* | chat | web | 起服务,落到 `/` |
|
|
39
|
+
* | task | web | 起服务,落到 `/task` |
|
|
40
|
+
*/
|
|
41
|
+
export function resolvePlan({ topic, surface, port = WEB_PORT } = {}) {
|
|
42
|
+
const t = topic === 'task' ? 'task' : 'chat';
|
|
43
|
+
const s = surface === 'web' ? 'web' : 'cli';
|
|
44
|
+
if (s === 'web') {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'web',
|
|
47
|
+
topic: t,
|
|
48
|
+
entry: t === 'task' ? 'task' : 'web',
|
|
49
|
+
path: t === 'task' ? '/task' : '/',
|
|
50
|
+
url: `http://127.0.0.1:${port}${t === 'task' ? '/task' : '/'}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { kind: 'cli', topic: t, interactive: true, needWorkDir: t === 'task' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 把一行输入解析成选项值:支持序号 `1`/`2`、空回车(取默认)、也支持直接写 `task` / `web` */
|
|
57
|
+
export function pickOption(answer, list, defaultIndex = 0) {
|
|
58
|
+
const s = strArg(answer).toLowerCase();
|
|
59
|
+
if (!s) return list[defaultIndex]?.value ?? '';
|
|
60
|
+
const n = Number(s);
|
|
61
|
+
if (Number.isInteger(n) && n >= 1 && n <= list.length) return list[n - 1].value;
|
|
62
|
+
return list.find((o) => o.value === s)?.value ?? '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 菜单正文 */
|
|
66
|
+
export function menuText(title, list) {
|
|
67
|
+
const lines = list.map((o, i) => ` ${i + 1}) ${o.label.padEnd(6, ' ')} ${o.desc}`);
|
|
68
|
+
return `${title}\n${lines.join('\n')}\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function launcherIntro() {
|
|
72
|
+
return (
|
|
73
|
+
'LLM API Gateway · 启动器\n' +
|
|
74
|
+
'两个问题选完就进入对应形态(直接回车 = 选 1)。\n' +
|
|
75
|
+
'想跳过这个菜单:-p "你的问题"(单轮)· -i(直接进命令行对话)· gateway-task(直接起网页)\n\n'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 问一次选择题;问不出合法值返回 null(**不会**替用户默认) */
|
|
80
|
+
async function askChoice(ask, title, list, log, attempts) {
|
|
81
|
+
log(menuText(title, list));
|
|
82
|
+
for (let i = 0; i < attempts; i++) {
|
|
83
|
+
const answer = await ask('选择', { default: '1' });
|
|
84
|
+
const value = pickOption(answer, list, 0);
|
|
85
|
+
if (value) return value;
|
|
86
|
+
log(` ✗ 请输入 1-${list.length}${list.map((o) => ` / ${o.value}`).join('')}\n`);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 跑一次启动器。
|
|
93
|
+
*
|
|
94
|
+
* @param {object} o
|
|
95
|
+
* @param {(label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>} o.ask
|
|
96
|
+
* @param {(s:string)=>void} [o.log]
|
|
97
|
+
* @param {(plan:object)=>Promise<{code:number}>} [o.startWeb] 真去起服务(注入,便于测试)
|
|
98
|
+
* @param {(plan:object)=>Promise<{code:number}>} [o.startCli] 真去进 REPL
|
|
99
|
+
* @param {string} [o.cwd] 任务工作目录的默认值(默认 process.cwd())
|
|
100
|
+
* @returns {Promise<{ok:boolean, code:number, plan?:object, error?:string}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function runLauncher({
|
|
103
|
+
ask = null,
|
|
104
|
+
log = () => {},
|
|
105
|
+
startWeb = null,
|
|
106
|
+
startCli = null,
|
|
107
|
+
cwd = process.cwd(),
|
|
108
|
+
port = WEB_PORT,
|
|
109
|
+
attempts = 3,
|
|
110
|
+
} = {}) {
|
|
111
|
+
if (!ask) return { ok: false, code: 1, error: '当前不是交互终端:请直接用参数表达意图(-p / -i / gateway-task)。' };
|
|
112
|
+
|
|
113
|
+
log(launcherIntro());
|
|
114
|
+
|
|
115
|
+
const topic = await askChoice(ask, '你要做什么?', TOPICS, log, attempts);
|
|
116
|
+
if (!topic) return { ok: false, code: 1, error: '没有选出「做什么」,已退出(没有改动任何配置)。' };
|
|
117
|
+
|
|
118
|
+
const surface = await askChoice(ask, '要在哪里跑?', SURFACES, log, attempts);
|
|
119
|
+
if (!surface) return { ok: false, code: 1, error: '没有选出「在哪里跑」,已退出(没有改动任何配置)。' };
|
|
120
|
+
|
|
121
|
+
const plan = resolvePlan({ topic, surface, port });
|
|
122
|
+
|
|
123
|
+
// 任务 + 命令行:还得知道在哪个目录干活(这是任务态与纯对话唯一的区别)
|
|
124
|
+
if (plan.kind === 'cli' && plan.needWorkDir) {
|
|
125
|
+
const answer = await ask(`任务工作目录 [${cwd}]`, { default: cwd });
|
|
126
|
+
plan.workDir = strArg(answer) || cwd;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (plan.kind === 'web') {
|
|
130
|
+
log(`\n→ 起本机 Web UI:${plan.url}${plan.entry === 'task' ? '(任务页)' : '(聊天页)'}\n 停止:Ctrl+C\n\n`);
|
|
131
|
+
if (!startWeb) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startWeb。' };
|
|
132
|
+
return { ok: true, ...(await startWeb(plan)), plan };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
log(
|
|
136
|
+
`\n→ 命令行${plan.topic === 'task' ? `任务态(工作目录 ${plan.workDir},写入前会问你)` : '对话'}\n` +
|
|
137
|
+
' 退出:/exit 或 Ctrl+C\n\n',
|
|
138
|
+
);
|
|
139
|
+
if (!startCli) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startCli。' };
|
|
140
|
+
return { ok: true, ...(await startCli(plan)), plan };
|
|
141
|
+
}
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首次配置引导(`gateway-agent setup`)
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须有这一步:在这之前,「第一次用」的唯一出口是**报错** —— 缺密钥就退 1,
|
|
5
|
+
* 让用户自己去文档里拼 `config set` / 环境变量 / `.env`;网关地址更是提都没提。
|
|
6
|
+
* 别的 CLI 工具装完都会问一次「服务地址是什么、凭据是什么」,本项目缺的正是这一步。
|
|
7
|
+
*
|
|
8
|
+
* 三条设计约束:
|
|
9
|
+
* 1. **交互与业务分离**:问题由调用方注入(`ask`),所以测试能喂固定答案,不需要真终端;
|
|
10
|
+
* 2. **非交互可配**:`setup --base-url … --key …` 一条命令配完,CI 里能跑;
|
|
11
|
+
* 3. **不另立一套读写**:地址走 `writeSettings`(→ `config.json`),密钥走 `writeSecret`
|
|
12
|
+
* (→ `credentials.json`),校验口径与网页设置面板、`config set` 完全同一份。
|
|
13
|
+
*
|
|
14
|
+
* 「默认值」的处理是这套东西的关键:默认值**只在提问时以 `[默认]` 形式给出**,
|
|
15
|
+
* 用户回车才采用、也可以直接覆盖 —— 不做「悄悄用默认值跑起来」这件事。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { strArg } from './common.js';
|
|
19
|
+
import { settingsFilePath, writeSettings, resolveSettings } from './settings.js';
|
|
20
|
+
import { secretFilePath, writeSecret, resolveSecretKey, KEY_SHAPE, SECRET_FILE_NAME } from './secrets.js';
|
|
21
|
+
|
|
22
|
+
/** 地址校验:与 settings 白名单同一口径(lib/settings.js:65) */
|
|
23
|
+
export const HTTP_URL_RE = /^https?:\/\/[^\s]+$/i;
|
|
24
|
+
|
|
25
|
+
/** 校验网关地址;通过返回 '',否则返回一句人话 */
|
|
26
|
+
export function validateBaseUrl(v) {
|
|
27
|
+
const s = strArg(v);
|
|
28
|
+
if (!s) return '网关地址不能为空';
|
|
29
|
+
if (!HTTP_URL_RE.test(s)) return '网关地址需要以 http:// 或 https:// 开头';
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 校验密钥:与 lib/secrets.js 的 KEY_SHAPE 同一口径 */
|
|
34
|
+
export function validateKey(v) {
|
|
35
|
+
const s = strArg(v);
|
|
36
|
+
if (!s) return '网关密钥不能为空';
|
|
37
|
+
if (!KEY_SHAPE.test(s)) {
|
|
38
|
+
return `密钥形状不对:需要以 sk- 开头且长度足够(收到 ${s.length} 个字符,前缀 ${s.slice(0, 3)})`;
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 这台机器是不是「全新、还没配过」。
|
|
45
|
+
*
|
|
46
|
+
* 判据是「四条来源一条都没有」:环境变量 / `.env`(会被 loadDotEnv 灌进 env)、
|
|
47
|
+
* `config.json`、`credentials.json`。任何一条有值就说明用户配过,不该再拿向导烦他。
|
|
48
|
+
* 与启动横幅那句「这台机器还没有任何配置」用的是同一套事实。
|
|
49
|
+
*/
|
|
50
|
+
export function isFreshMachine({ env = process.env, file = settingsFilePath(env), secretFile = secretFilePath(env) } = {}) {
|
|
51
|
+
if (resolveSecretKey({ env, file: secretFile }).key) return false;
|
|
52
|
+
if (!strArg(env.GATEWAY_BASE_URL) && !resolveSettings({ file, env }).exists) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 脱敏:只留前 6 后 4,给向导的回执用(`config get key` 也是这个口径) */
|
|
57
|
+
export function maskKeyish(key) {
|
|
58
|
+
const s = strArg(key);
|
|
59
|
+
if (s.length <= 10) return s ? `${s.slice(0, 3)}****` : '';
|
|
60
|
+
return `${s.slice(0, 6)}****${s.slice(-4)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 向导正文(纯文本,便于测试逐字断言) */
|
|
64
|
+
export function setupIntro({ baseUrlDefault }) {
|
|
65
|
+
return [
|
|
66
|
+
'LLM API Gateway · 首次配置',
|
|
67
|
+
'',
|
|
68
|
+
'这一步问两件事,都只写在本机(不会上传、不会进 git):',
|
|
69
|
+
` 1) 网关地址:你部署的 LLM API Gateway 的地址(默认 ${baseUrlDefault})`,
|
|
70
|
+
' 2) 网关密钥:网关后台发放的 sk- 密钥',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 跑一次设置向导。
|
|
77
|
+
*
|
|
78
|
+
* @param {object} o
|
|
79
|
+
* @param {(label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>} o.ask
|
|
80
|
+
* 读一行;**返回原始输入**(可能是空串),默认值由本函数套用 —— 提问方不承担校验
|
|
81
|
+
* @param {(s:string) => void} [o.log] 正文输出(默认丢弃,调用方传 process.stdout.write)
|
|
82
|
+
* @param {object} [o.args] `setup` 的参数(`--base-url` / `--key` 给了就不问)
|
|
83
|
+
* @param {number} [o.attempts] 单个问题的最多重问次数
|
|
84
|
+
* @returns {Promise<{ok:boolean, code:number, baseUrl?:string, key?:string,
|
|
85
|
+
* file?:string, secretFile?:string, error?:string}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function runSetup({
|
|
88
|
+
ask = null,
|
|
89
|
+
log = () => {},
|
|
90
|
+
args = {},
|
|
91
|
+
env = process.env,
|
|
92
|
+
file = settingsFilePath(env),
|
|
93
|
+
secretFile = secretFilePath(env),
|
|
94
|
+
attempts = 3,
|
|
95
|
+
} = {}) {
|
|
96
|
+
const resolved = resolveSettings({ file, env });
|
|
97
|
+
const currentBase = strArg(args['base-url']) || strArg(resolved.values.baseUrl) || 'http://127.0.0.1:9000';
|
|
98
|
+
const givenBase = strArg(args['base-url']);
|
|
99
|
+
const givenKey = strArg(args.key);
|
|
100
|
+
|
|
101
|
+
// 参数已经给全 → 纯写盘,一个问题都不问(CI / 脚本路径)
|
|
102
|
+
const needAsk = !(givenBase && givenKey);
|
|
103
|
+
if (needAsk && !ask) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
code: 1,
|
|
107
|
+
error: '当前不是交互终端:请用 `gateway-agent setup --base-url <地址> --key sk-xxx` 一次配完。',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log(setupIntro({ baseUrlDefault: currentBase }));
|
|
112
|
+
|
|
113
|
+
/* ---------- 1) 网关地址 ---------- */
|
|
114
|
+
let baseUrl = givenBase;
|
|
115
|
+
if (!baseUrl) {
|
|
116
|
+
for (let i = 0; i < attempts; i++) {
|
|
117
|
+
const answer = await ask('网关地址', { default: currentBase });
|
|
118
|
+
const value = strArg(answer) || currentBase; // 回车 = 采用方括号里的默认值
|
|
119
|
+
const err = validateBaseUrl(value);
|
|
120
|
+
if (!err) {
|
|
121
|
+
baseUrl = value;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
log(` ✗ ${err}\n`);
|
|
125
|
+
}
|
|
126
|
+
if (!baseUrl) return { ok: false, code: 1, error: '网关地址没填对,已取消(没有写入任何文件)。' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ---------- 2) 网关密钥 ---------- */
|
|
130
|
+
let key = givenKey;
|
|
131
|
+
if (!key) {
|
|
132
|
+
for (let i = 0; i < attempts; i++) {
|
|
133
|
+
const answer = await ask('网关密钥', { mask: true });
|
|
134
|
+
const err = validateKey(answer);
|
|
135
|
+
if (!err) {
|
|
136
|
+
key = strArg(answer);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
log(` ✗ ${err}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (!key) return { ok: false, code: 1, error: '网关密钥没填对,已取消(没有写入任何文件)。' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ---------- 3) 落盘:复用既有实现,原子写与权限都在里面 ---------- */
|
|
145
|
+
let wrote;
|
|
146
|
+
try {
|
|
147
|
+
// 地址去掉尾部斜杠,与 resolveBaseUrl / Web 侧同一口径
|
|
148
|
+
const res = writeSettings({ baseUrl: baseUrl.replace(/\/+$/, '') }, { file });
|
|
149
|
+
wrote = writeSecret(key, { file: secretFile });
|
|
150
|
+
log(
|
|
151
|
+
'\n已写入:\n' +
|
|
152
|
+
` 网关地址 ${baseUrl.replace(/\/+$/, '')}\n → ${res.file}\n` +
|
|
153
|
+
` 网关密钥 ${maskKeyish(key)}\n → ${wrote.file}${wrote.mode ? `(权限 ${wrote.mode.toString(8)})` : '(Windows:靠用户目录 ACL)'}\n` +
|
|
154
|
+
(wrote.warning ? `${wrote.warning}\n` : ''),
|
|
155
|
+
);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return { ok: false, code: 1, error: `写入配置失败:${e.message}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
log(
|
|
161
|
+
'\n接下来:\n' +
|
|
162
|
+
' gateway-agent 命令行对话(现在直接可用)\n' +
|
|
163
|
+
' gateway-task 本机 Web UI 与任务页 → http://127.0.0.1:3100\n' +
|
|
164
|
+
' gateway-agent config list 看所有配置项与它们的「来源」\n' +
|
|
165
|
+
'改网关地址 / 模型:gateway-agent config set baseUrl|model …,或在任务页「设置 → 其他配置」里改。\n' +
|
|
166
|
+
'密钥明文只在网关后台能重新生成;这里只回掩码。\n',
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return { ok: true, code: 0, baseUrl: baseUrl.replace(/\/+$/, ''), key, file: resolveSettings({ file, env }).file, secretFile: wrote.file, secretName: SECRET_FILE_NAME };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `setup --help` 的正文 */
|
|
173
|
+
export function setupHelp() {
|
|
174
|
+
return `用法:gateway-agent setup [--base-url <地址>] [--key sk-xxx]
|
|
175
|
+
|
|
176
|
+
首次配置引导:问一次「网关地址」和「网关密钥」,写进本机的两个文件 ——
|
|
177
|
+
网关地址 → ${settingsFilePath()}
|
|
178
|
+
网关密钥 → ${secretFilePath()}(不进 config.json)
|
|
179
|
+
|
|
180
|
+
--base-url <地址> 直接给定,不再提问(必须 http:// 或 https:// 开头)
|
|
181
|
+
--key sk-xxx 直接给定,不再提问
|
|
182
|
+
两个都给 → 完全不问,适合脚本 / CI
|
|
183
|
+
一个都不给 → 进入交互问答(默认值以 [方括号] 给出,回车即采用)
|
|
184
|
+
|
|
185
|
+
等价做法(任选,读写的是同一份配置):
|
|
186
|
+
网页「设置」面板(任务页「其他配置」里地址与密钥都有)
|
|
187
|
+
gateway-agent config set baseUrl <地址> / config set key sk-xxx
|
|
188
|
+
环境变量 GATEWAY_BASE_URL / SK / GATEWAY_KEY,或当前目录的 .env
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 造一个真终端问答器(TTY 专用)。
|
|
194
|
+
*
|
|
195
|
+
* 不用 readline 的私有 `_writeToOutput` 做掩码:那种写法随 Node 版本变,
|
|
196
|
+
* 这里直接上 raw 模式自己回显 —— 普通问题回显字符,密钥只回 `*`。
|
|
197
|
+
* 非 TTY 返回 null,调用方据此走 `--base-url/--key` 的非交互路径。
|
|
198
|
+
*
|
|
199
|
+
* @returns {null | ((label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>)}
|
|
200
|
+
*/
|
|
201
|
+
export function createTerminalAsk({ input = process.stdin, output = process.stdout } = {}) {
|
|
202
|
+
if (!input.isTTY) return null;
|
|
203
|
+
|
|
204
|
+
function readLine(prompt, mask) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
output.write(prompt);
|
|
207
|
+
const wasRaw = input.isRaw === true;
|
|
208
|
+
let text = '';
|
|
209
|
+
const finish = () => {
|
|
210
|
+
input.removeListener('data', onData);
|
|
211
|
+
try {
|
|
212
|
+
input.setRawMode(wasRaw);
|
|
213
|
+
} catch {
|
|
214
|
+
/* 平台不支持就保持原状 */
|
|
215
|
+
}
|
|
216
|
+
input.pause();
|
|
217
|
+
};
|
|
218
|
+
const onData = (chunk) => {
|
|
219
|
+
for (const ch of String(chunk)) {
|
|
220
|
+
if (ch === '\r' || ch === '\n') {
|
|
221
|
+
finish();
|
|
222
|
+
output.write('\n');
|
|
223
|
+
resolve(text);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (ch === '\u0003') {
|
|
227
|
+
// Ctrl+C:别把终端留在 raw 模式里
|
|
228
|
+
finish();
|
|
229
|
+
output.write('\n');
|
|
230
|
+
process.exit(130);
|
|
231
|
+
}
|
|
232
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
233
|
+
if (text) {
|
|
234
|
+
text = text.slice(0, -1);
|
|
235
|
+
output.write('\b \b');
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (ch < ' ') continue; // 其余控制字符(方向键等)忽略
|
|
240
|
+
text += ch;
|
|
241
|
+
output.write(mask ? '*' : ch);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
input.setRawMode(true);
|
|
246
|
+
} catch {
|
|
247
|
+
/* 某些环境(如 Windows 的旧终端)可能拒绝,退化成带回显 */
|
|
248
|
+
}
|
|
249
|
+
input.setEncoding('utf8');
|
|
250
|
+
input.resume();
|
|
251
|
+
input.on('data', onData);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return (label, { default: def = '', mask = false } = {}) => {
|
|
256
|
+
const suffix = def ? ` [${def}]` : '';
|
|
257
|
+
const hint = mask ? '(sk- 开头,输入不回显)' : '';
|
|
258
|
+
return readLine(`${label}${suffix}${hint}:`, mask);
|
|
259
|
+
};
|
|
260
|
+
}
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -61,6 +61,9 @@ const dom = {
|
|
|
61
61
|
setHistoryChars: document.getElementById('set-history-chars'),
|
|
62
62
|
setKey: document.getElementById('set-key'),
|
|
63
63
|
keyHint: document.getElementById('key-hint'),
|
|
64
|
+
setBaseUrl: document.getElementById('set-base-url'),
|
|
65
|
+
baseHint: document.getElementById('base-hint'),
|
|
66
|
+
btnSaveBase: document.getElementById('btn-save-base'),
|
|
64
67
|
btnSaveKey: document.getElementById('btn-save-key'),
|
|
65
68
|
btnRefreshModels: document.getElementById('btn-refresh-models'),
|
|
66
69
|
btnReset: document.getElementById('btn-reset'),
|
|
@@ -849,6 +852,10 @@ async function loadConfig() {
|
|
|
849
852
|
const resp = await fetch('/api/config');
|
|
850
853
|
state.config = await resp.json();
|
|
851
854
|
setInfo(dom.infoBase, state.config.baseUrl);
|
|
855
|
+
// 地址是可改项(下一行的 infoBase 只是只读回显):回填输入框,但别踩用户正在输入的内容
|
|
856
|
+
if (dom.setBaseUrl && document.activeElement !== dom.setBaseUrl) {
|
|
857
|
+
dom.setBaseUrl.value = state.config.baseUrl || '';
|
|
858
|
+
}
|
|
852
859
|
setInfo(dom.infoKey, state.config.hasKey
|
|
853
860
|
? `${state.config.keyMask}${state.config.keySourceText ? ` · ${state.config.keySourceText}` : ''}`
|
|
854
861
|
: '未配置');
|
|
@@ -876,6 +883,38 @@ async function loadConfig() {
|
|
|
876
883
|
}
|
|
877
884
|
}
|
|
878
885
|
|
|
886
|
+
/**
|
|
887
|
+
* 保存网关地址。
|
|
888
|
+
*
|
|
889
|
+
* 这一页的设置项大多存在浏览器里,地址和密钥是例外:它们决定**服务端**往哪发请求、用什么凭据,
|
|
890
|
+
* 所以必须走 `PUT /api/settings`(地址 → config.json,密钥 → credentials.json)。
|
|
891
|
+
* 任务页「设置 → 其他配置 → 网关地址」走的是同一个接口、同一份文件,两处不会各存一处。
|
|
892
|
+
*/
|
|
893
|
+
async function saveBaseUrl() {
|
|
894
|
+
const value = String(dom.setBaseUrl.value || '').trim();
|
|
895
|
+
if (!value) {
|
|
896
|
+
setStatus('网关地址不能为空(默认 http://127.0.0.1:9000)', 'err');
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
dom.btnSaveBase.disabled = true;
|
|
900
|
+
try {
|
|
901
|
+
const resp = await fetch('/api/settings', {
|
|
902
|
+
method: 'PUT',
|
|
903
|
+
headers: { 'content-type': 'application/json' },
|
|
904
|
+
body: JSON.stringify({ values: { baseUrl: value } }),
|
|
905
|
+
});
|
|
906
|
+
const data = await resp.json().catch(() => ({}));
|
|
907
|
+
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
908
|
+
await loadConfig();
|
|
909
|
+
await loadModels();
|
|
910
|
+
setStatus(data.note || `网关地址已保存:${state.config?.baseUrl || value}`, 'ok');
|
|
911
|
+
} catch (e) {
|
|
912
|
+
setStatus(`保存网关地址失败:${e.message}`, 'err');
|
|
913
|
+
} finally {
|
|
914
|
+
dom.btnSaveBase.disabled = false;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
879
918
|
/**
|
|
880
919
|
* 保存密钥。
|
|
881
920
|
*
|
|
@@ -1022,6 +1061,18 @@ function init() {
|
|
|
1022
1061
|
dom.setSystem.addEventListener('blur', readSettingsFromUI);
|
|
1023
1062
|
|
|
1024
1063
|
dom.btnRefreshModels.addEventListener('click', loadModels);
|
|
1064
|
+
// 网关地址:和密钥一样属于**服务端配置**(它决定服务端往哪发请求,写进 config.json),
|
|
1065
|
+
// 所以不能像这页其它项那样存浏览器本地 —— 走服务端的 PUT /api/settings,
|
|
1066
|
+
// 与任务页「设置 → 其他配置 → 网关地址」是同一条路、同一份文件。
|
|
1067
|
+
if (dom.btnSaveBase) {
|
|
1068
|
+
dom.btnSaveBase.addEventListener('click', saveBaseUrl);
|
|
1069
|
+
dom.setBaseUrl.addEventListener('keydown', (e) => {
|
|
1070
|
+
if (e.key === 'Enter') {
|
|
1071
|
+
e.preventDefault();
|
|
1072
|
+
saveBaseUrl();
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1025
1076
|
// 密钥:唯一的写入路径是服务端的 PUT /api/settings(它写 credentials.json,不写 config.json)。
|
|
1026
1077
|
// 这页的设置项大多存在浏览器里,密钥是例外 —— 它是服务端的事,所以单独一个「保存密钥」按钮。
|
|
1027
1078
|
dom.btnSaveKey.addEventListener('click', saveKey);
|
package/public/index.html
CHANGED
|
@@ -110,6 +110,13 @@
|
|
|
110
110
|
<textarea id="set-system" rows="4" placeholder="留空则不发送 system 消息"></textarea>
|
|
111
111
|
</label>
|
|
112
112
|
|
|
113
|
+
<label class="field">
|
|
114
|
+
<span>网关地址</span>
|
|
115
|
+
<input id="set-base-url" type="text" autocomplete="off" spellcheck="false"
|
|
116
|
+
placeholder="http://127.0.0.1:9000" />
|
|
117
|
+
<small id="base-hint">http:// 或 https:// 开头。与「网关密钥」一样属于服务端配置(写 config.json,不进浏览器本地),改完立即生效。</small>
|
|
118
|
+
</label>
|
|
119
|
+
|
|
113
120
|
<label class="field">
|
|
114
121
|
<span>网关密钥</span>
|
|
115
122
|
<input id="set-key" type="password" autocomplete="off" spellcheck="false"
|
|
@@ -145,6 +152,7 @@
|
|
|
145
152
|
</label>
|
|
146
153
|
|
|
147
154
|
<div class="settings-actions">
|
|
155
|
+
<button class="btn ghost" id="btn-save-base" type="button">保存地址</button>
|
|
148
156
|
<button class="btn ghost" id="btn-save-key" type="button">保存密钥</button>
|
|
149
157
|
<button class="btn ghost" id="btn-refresh-models" type="button">刷新模型</button>
|
|
150
158
|
<button class="btn ghost" id="btn-reset" type="button">恢复默认</button>
|
package/public/manual.html
CHANGED
|
@@ -75,6 +75,19 @@
|
|
|
75
75
|
<b>注意:装完还差一步「配密钥」</b> —— 但这一步<b>不用手写 <code>.env</code></b>:起服务后在界面「设置」里填一次,
|
|
76
76
|
或者 <code>gateway-agent config set key sk-xxx</code>。没配之前页面能打开,只是发消息会被提示去配密钥(见下面「首次配置」)。
|
|
77
77
|
</p>
|
|
78
|
+
<p class="manual-note">
|
|
79
|
+
<b>装完先做三件事(都可以让命令自己引导)</b>:
|
|
80
|
+
① <b>配一次</b>:<code>gateway-agent setup</code> 会问「网关地址」和「网关密钥」并写进本机
|
|
81
|
+
(想一步到位就 <code>gateway-agent setup --base-url http://127.0.0.1:9000 --key sk-xxx</code>;
|
|
82
|
+
<b>全新机器首次运行会自动进入这一步</b>,不再只丢一句「缺少密钥」)。
|
|
83
|
+
② <b>选做什么、在哪跑</b>:<b>裸敲</b> <code>llm-api-gateway-cli</code>(或 <code>gateway-agent</code>)会先问
|
|
84
|
+
「① 对话 / ② 任务」,再问「① 命令行窗口 / ② 网页 UI」,选完直接进入对应形态 —— 不替你默认。
|
|
85
|
+
③ 只有选了「网页 UI」才会起服务;<b>起服务后要自己用浏览器</b>打开
|
|
86
|
+
<code>http://127.0.0.1:3100/</code>(聊天)或 <code>/task</code>(任务),
|
|
87
|
+
而<b>这一页手册本身就是 3100 上的 <code>/manual</code></b>,也要服务起着才看得到。
|
|
88
|
+
网关地址与密钥在任务页「设置 → 其他配置」里也能改;命令行是 <code>gateway-agent config set baseUrl|key …</code>。
|
|
89
|
+
想跳过启动器:<code>-p "问题"</code>(单轮)· <code>-i</code>(直接进对话)· <code>gateway-task</code>(直接起网页)。
|
|
90
|
+
</p>
|
|
78
91
|
<p>
|
|
79
92
|
<b>前置条件只有一个:Node.js ≥ 18</b>(用 <code>node -v</code> 自检)。脚本<b>不会替你装 Node</b> ——
|
|
80
93
|
缺失或版本过低时它明确报错并停下,不动你的系统运行时。
|
|
@@ -159,9 +172,18 @@ $env:LLM_GATEWAY_INSTALL_BASE = 'https://your-mirror.example.com'; irm .../insta
|
|
|
159
172
|
</p>
|
|
160
173
|
<ol>
|
|
161
174
|
<li>
|
|
162
|
-
<b
|
|
175
|
+
<b>路线零(推荐,装完第一步):<code>gateway-agent setup</code></b>
|
|
176
|
+
<div class="code-block"><code>gateway-agent setup # 交互问答:先问网关地址,再问密钥(不回显)
|
|
177
|
+
gateway-agent setup --base-url http://127.0.0.1:9000 --key sk-xxx # 两个都给就不问,适合脚本 / CI</code></div>
|
|
178
|
+
地址写进 <code>config.json</code>、密钥写进 <code>credentials.json</code>,与界面 / <code>config set</code> 是同一份。
|
|
179
|
+
默认值以 <code>[方括号]</code> 形式给出,<b>回车才采用</b>,也可以直接改;填错会重问,连错三次则<b>一个文件都不写</b>。
|
|
180
|
+
全新机器首次运行(无 config.json / credentials.json / 环境变量 / <code>.env</code>)会自动进入这一步;
|
|
181
|
+
<b>非交互终端(管道、CI)不会触发</b>,仍按老样子报错退出。
|
|
182
|
+
</li>
|
|
183
|
+
<li>
|
|
184
|
+
<b>路线一:起服务 → 在界面里填</b>
|
|
163
185
|
<div class="code-block"><code>gateway-web # 或 gateway-task,等价于 node server.js / node task-server.js
|
|
164
|
-
# 打开 http://127.0.0.1:3100/task →
|
|
186
|
+
# 打开 http://127.0.0.1:3100/task → 右上「设置」→「其他配置」→ 网关地址 / 网关密钥</code></div>
|
|
165
187
|
没有密钥时启动横幅会打印「密钥 未配置」,并给出三种填法;页面的信息行显示「未配置」,发消息会被挡下并提示你打开设置
|
|
166
188
|
(聊天页与任务页都有这个输入框,写的是同一个文件)。<b>保存后立即生效,不用重启服务。</b>
|
|
167
189
|
</li>
|
|
@@ -237,9 +259,15 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
|
|
|
237
259
|
</li>
|
|
238
260
|
</ol>
|
|
239
261
|
<p class="manual-note">
|
|
240
|
-
|
|
262
|
+
<b>卸载:按你的安装路线选一条。</b><b>npm 全局安装</b>的用
|
|
263
|
+
<code>npm uninstall -g llm-api-gateway-cli</code>(<b><code>-g</code> 不能少</b>,少了是去当前目录的
|
|
264
|
+
<code>node_modules</code> 里找它)—— 包目录与 <code>gateway-agent</code> / <code>gateway-web</code> /
|
|
265
|
+
<code>gateway-task</code> / <code>llm-api-gateway-cli</code> 等命令入口会一起删掉;
|
|
266
|
+
验一下干不干净:<code>where gateway-agent</code>(POSIX:<code>which gateway-agent</code>)报找不到就是干净了。
|
|
267
|
+
<b>脚本安装</b>的则删掉安装目录(<code>rm -rf ~/.llm-api-gateway</code>,Windows 删 <code>$HOME\.llm-api-gateway</code>),
|
|
241
268
|
再把写进 shell 启动文件 / 用户 PATH 的那一行删掉。数据(任务与配置,在 <code>~/.llm-api-gateway-cli</code>)
|
|
242
|
-
|
|
269
|
+
与安装目录是两回事,卸载不会连带删数据 —— 想连密钥与任务一起清掉,再删那个目录
|
|
270
|
+
(<code>credentials.json</code> 里有你的 <code>sk-</code> 密钥,删掉就要重新配一次)。
|
|
243
271
|
</p>
|
|
244
272
|
<p class="manual-note">
|
|
245
273
|
更细的说明(换镜像、源码安装、CI 产物)见仓库
|