llm-api-gateway-cli 1.0.4 → 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/lib/hub.js CHANGED
@@ -32,9 +32,19 @@ import {
32
32
  resolveStatic,
33
33
  sessionBaggage,
34
34
  } from './common.js';
35
+ // 命令行侧的输出语言:只要 `pick()`,语言由入口(runHub)在输出之前设一次
36
+ import { pick, setLang, resolveLang } from './i18n.js';
35
37
  import { availableTools, checkWorkDir, browseDir, listRoots, describeCall, setBashEnabled, isBashEnabled } from './tools.js';
36
38
  // MCP 工具清单按**当次密钥**从网关取:页面上的工具列表与任务运行共用这一份缓存
37
- import { refreshMcpTools } from './mcp.js';
39
+ import { refreshMcpTools, mcpToolList } from './mcp.js';
40
+ // 网页版 MCP 面板(用户视角):管理面复用 CLI 那一套,**密钥只留在服务端**
41
+ import {
42
+ configureMcpAdmin,
43
+ getMcpBinding,
44
+ patchMcpBinding,
45
+ createMcpServer,
46
+ deleteMcpServer,
47
+ } from './mcpadmin.js';
38
48
  import { readMemory, initInstructions } from './memory.js';
39
49
  import {
40
50
  runAgent,
@@ -56,6 +66,14 @@ import { buildTaskSystemPrompt, pushUser } from './runner.js';
56
66
  import { savePlanDoc, lastAssistantText, firstUserText } from './plandoc.js';
57
67
  import { TaskStore, resolveStoreDir, isSafeId, TTL_MS as TASK_TTL_MS } from './taskstore.js';
58
68
  import { createRunStore } from './runstore.js';
69
+ // 命令行会话(模式六)的**只读**查看:与 cli-agent 用同一处落盘(sessions/),只读不写
70
+ import {
71
+ createSessionStore,
72
+ resolveSessionDir,
73
+ SESSION_TTL_DAYS,
74
+ SESSION_TTL_MS,
75
+ MAX_SESSIONS,
76
+ } from './sessionstore.js';
59
77
  import {
60
78
  createTaskSessionStore,
61
79
  loadOrCreateTaskSession,
@@ -124,7 +142,24 @@ const STORE_SOURCE_TEXT = {
124
142
  temp: '主目录不可写,退到系统临时目录',
125
143
  };
126
144
 
127
- const HELP = `用法:node server.js [选项]
145
+ /** 上面几句的英文分支;认不出的来源仍照原样打印(同 pick 的回落口径) */
146
+ const STORE_SOURCE_TEXT_EN = {
147
+ explicit: 'given by --store',
148
+ 'user-data': '.llm-api-gateway-cli under your home directory',
149
+ legacy: 'old location (home directory not writable — see the warning below)',
150
+ temp: 'home directory not writable, fell back to the system temp directory',
151
+ };
152
+
153
+ /**
154
+ * 存储目录来源的双语取词。
155
+ * 必须包成函数:模块顶层调 `pick()` 会赶在 `setLang()` 之前求值,永远拿到中文(lib/i18n.js 第 3 条)。
156
+ */
157
+ function storeSourceText(source) {
158
+ const zh = STORE_SOURCE_TEXT[source] || source;
159
+ return pick(zh, STORE_SOURCE_TEXT_EN[source] || zh);
160
+ }
161
+
162
+ const HELP_ZH = `用法:node server.js [选项]
128
163
 
129
164
  本地 Web UI:聊天(模式四)、任务(模式五)、操作手册跑在同一个端口、同一个页面里,
130
165
  顶部 Tab 切换(不再新开标签页);三个面板也能各自单独打开。
@@ -133,6 +168,7 @@ const HELP = `用法:node server.js [选项]
133
168
  http://127.0.0.1:${DEFAULT_PORT}/chat 只打开聊天面板
134
169
  http://127.0.0.1:${DEFAULT_PORT}/task 只打开任务面板(选目录,模型真读写)
135
170
  http://127.0.0.1:${DEFAULT_PORT}/manual 只打开操作手册
171
+ http://127.0.0.1:${DEFAULT_PORT}/sessions 命令行会话的只读存档(终端 gateway-agent -i 聊过的对话)
136
172
 
137
173
  选项:
138
174
  --port <端口> 监听端口(默认 ${DEFAULT_PORT},也可用 WEB_PORT / TASK_WEB_PORT)
@@ -146,6 +182,7 @@ const HELP = `用法:node server.js [选项]
146
182
  --allow-remote-fs 允许非本机访问时开放目录浏览与任务记录接口(危险)
147
183
  --allow-bash 开启 bash 工具,让任务页的模型能真的执行命令(默认关闭;危险)
148
184
  --verbose 打印每个请求的转发日志
185
+ --lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)
149
186
  -h, --help 显示帮助
150
187
 
151
188
  三种审批模式(任务页可随时切换):
@@ -157,6 +194,47 @@ const HELP = `用法:node server.js [选项]
157
194
  单个任务默认最多 ${AGENT_LIMITS.MAX_STEPS} 轮模型调用(--max-steps / TASK_MAX_STEPS 可调)。撞到上限不会「干到一半就停」:先自动走一轮不带工具的收尾把结论给你,页面再给一个「继续执行」按钮接着干。待批准的挂起态保留 ${AGENT_LIMITS.APPROVAL_TTL_MS / 60000} 分钟、最多 ${AGENT_LIMITS.MAX_RUNS} 个,且会落盘 —— 刷新页面或重启服务后仍可批准。
158
195
  `;
159
196
 
197
+ /** 英文分支:与 HELP_ZH 逐行对应(选项顺序、缩进、占位符、插值都一致) */
198
+ const HELP_EN = `Usage: node server.js [options]
199
+
200
+ Local Web UI: chat (mode 4), tasks (mode 5) and the manual share one port and one page,
201
+ switched by the tabs at the top (no extra browser tabs); each panel also opens on its own.
202
+
203
+ http://127.0.0.1:${DEFAULT_PORT}/ chat / task / manual (tab shell)
204
+ http://127.0.0.1:${DEFAULT_PORT}/chat chat panel only
205
+ http://127.0.0.1:${DEFAULT_PORT}/task task panel only (pick a directory, the model really reads and writes)
206
+ http://127.0.0.1:${DEFAULT_PORT}/manual manual only
207
+ http://127.0.0.1:${DEFAULT_PORT}/sessions read-only archive of CLI sessions (gateway-agent -i)
208
+
209
+ Options:
210
+ --port <port> listen port (default ${DEFAULT_PORT}; WEB_PORT / TASK_WEB_PORT also work)
211
+ --host <address> listen address (default 127.0.0.1, local access only)
212
+ -m, --model <model> default model (default ${DEFAULT_MODEL})
213
+ -k, --key <sk-key> gateway key (or env SK/GATEWAY_KEY, or .env)
214
+ --base-url <url> gateway base URL (default ${DEFAULT_BASE_URL})
215
+ --store <dir> task data directory (default ~/.llm-api-gateway-cli; TASK_STORE_DIR also works)
216
+ --mode <mode> default approval mode: manual / auto / plan (default manual)
217
+ --max-steps <n> max model calls per task (default ${AGENT_LIMITS.MAX_STEPS}; TASK_MAX_STEPS also works)
218
+ --allow-remote-fs allow directory browsing and task-record APIs when not bound to localhost (dangerous)
219
+ --allow-bash enable the bash tool so the task-page model can really run commands (off by default; dangerous)
220
+ --verbose print a forward log line for every request
221
+ --lang <zh|en> Output language (default zh; env LLM_GATEWAY_LANG)
222
+ -h, --help show this help
223
+
224
+ Three approval modes (switchable any time on the task page):
225
+ manual every write waits for your click in the UI (default)
226
+ auto writes go straight through, no interruptions; the UI still lists each change
227
+ (with --allow-bash, commands take this path too: no per-command confirmation)
228
+ plan the model gets read-only tools; plan first, touch no files, execute after you approve
229
+
230
+ A task runs at most ${AGENT_LIMITS.MAX_STEPS} model calls by default (tune with --max-steps / TASK_MAX_STEPS). Hitting the limit does not stop it half-way: one final tool-free call wraps up with a conclusion, then the page offers a "continue" button. Pending approvals are kept for ${AGENT_LIMITS.APPROVAL_TTL_MS / 60000} minutes (at most ${AGENT_LIMITS.MAX_RUNS}) and are persisted — you can still approve after a page refresh or a restart.
231
+ `;
232
+
233
+ /** `--help` 的正文:必须在这里取词(模块顶层 pick 会赶在 setLang 之前) */
234
+ function helpText() {
235
+ return pick(HELP_ZH, HELP_EN);
236
+ }
237
+
160
238
  const VALUE_FLAGS = {
161
239
  '--port': 'port',
162
240
  '--host': 'host',
@@ -168,6 +246,7 @@ const VALUE_FLAGS = {
168
246
  '--store': 'store',
169
247
  '--mode': 'mode',
170
248
  '--max-steps': 'max-steps',
249
+ '--lang': 'lang',
171
250
  };
172
251
  const BOOL_FLAGS = { '--verbose': 'verbose', '--allow-remote-fs': 'allow-remote-fs', '--allow-bash': 'allow-bash', '--help': 'help', '-h': 'help' };
173
252
 
@@ -205,6 +284,10 @@ function pageFile(urlPath) {
205
284
  if (urlPath === '/index.html') return 'index.html'; // 老链接:直接给聊天页
206
285
  if (urlPath === '/task' || urlPath === '/task/' || urlPath === '/task.html') return 'task.html';
207
286
  if (urlPath === '/manual' || urlPath === '/manual/' || urlPath === '/manual.html') return 'manual.html';
287
+ // 命令行会话的只读存档(模式六):终端 `gateway-agent -i` 的对话,网页侧只读回放
288
+ if (urlPath === '/sessions' || urlPath === '/sessions/' || urlPath === '/sessions.html') return 'sessions.html';
289
+ // 网页版 MCP 面板(用户视角,密钥留在服务端)
290
+ if (urlPath === '/mcp' || urlPath === '/mcp/' || urlPath === '/mcp.html') return 'mcp.html';
208
291
  return null;
209
292
  }
210
293
 
@@ -997,6 +1080,193 @@ async function handleApprove(req, res, cfg) {
997
1080
  }
998
1081
  }
999
1082
 
1083
+ /* ---------- 网页版 MCP 面板(用户视角,密钥留在服务端) ----------
1084
+ * 背景:终端有 `/mcp`(bind/mode/add/rm),但用网页的人(任务页/聊天页)此前一行 MCP 都没有 ——
1085
+ * 用户手里只有 `sk-` 密钥、没有管理员登录,所以网页需要一个**用户视角**的面板。
1086
+ *
1087
+ * 关键设计:**浏览器不碰密钥**。页面只打本机的 `/api/mcp*`,hub 用服务端保存的
1088
+ * baseUrl/key 去调网关(与 `/api/chat` 完全同一套姿势)。管理动作(改绑/登记/删除)
1089
+ * 直接复用 `lib/mcpadmin.js` —— 那层的失败语义是「必须吵」,正好原样透给页面。
1090
+ */
1091
+
1092
+ /** 页面每次调用前同步一次连接:不依赖「先刷过工具清单」这个副作用(终端那边踩过同一个坑) */
1093
+ function syncMcpAdmin(cfg) {
1094
+ if (cfg.baseUrl || cfg.key) configureMcpAdmin({ baseUrl: cfg.baseUrl, key: cfg.key });
1095
+ }
1096
+
1097
+ /** mcpadmin 的失败对象 → HTTP 状态 + 页面可读的 {error, hint} */
1098
+ function mcpFail(res, r) {
1099
+ const code = Number(r && r.code) || 0;
1100
+ const status = code === 0 ? 400 : code;
1101
+ return sendJson(res, status, { error: (r && r.message) || '管理面调用失败', hint: (r && r.hint) || '' });
1102
+ }
1103
+
1104
+ /** 面板上要显示的工具清单(截断说明,避免一次传几百 KB) */
1105
+ function mcpToolsBrief() {
1106
+ return mcpToolList().map((t) => ({
1107
+ name: t.name,
1108
+ server: t.server,
1109
+ tool: t.tool,
1110
+ description: String(t.description || '').split('\n')[0].slice(0, 200),
1111
+ }));
1112
+ }
1113
+
1114
+ async function handleMcpPanel(req, res, cfg, p) {
1115
+ syncMcpAdmin(cfg);
1116
+ if (!cfg.key) {
1117
+ return sendJson(res, 409, {
1118
+ error: '还没有配置网关密钥:在「设置 → 网关密钥」里填 sk-xxx,或跑 gateway-agent config set key sk-xxx',
1119
+ hint: 'MCP 的绑定与登记都要用你手里这把密钥去问网关',
1120
+ });
1121
+ }
1122
+
1123
+ if (p === '/api/mcp') {
1124
+ if (req.method !== 'GET') return sendJson(res, 405, { error: `/api/mcp 只支持 GET,收到 ${req.method}` });
1125
+ const binding = await getMcpBinding();
1126
+ const info = await refreshMcpTools({ force: true, baseUrl: cfg.baseUrl, key: cfg.key });
1127
+ return sendJson(res, 200, {
1128
+ binding: binding.ok
1129
+ ? {
1130
+ mode: binding.mode,
1131
+ enabled: binding.enabled,
1132
+ bound: binding.bound,
1133
+ addable: binding.addable,
1134
+ mine: binding.mine,
1135
+ selectableIds: binding.selectableIds,
1136
+ selfServer: binding.selfServer,
1137
+ }
1138
+ : null,
1139
+ bindingError: binding.ok ? null : { error: binding.message, hint: binding.hint },
1140
+ tools: mcpToolsBrief(),
1141
+ toolsError: info && info.error ? String(info.error) : null,
1142
+ });
1143
+ }
1144
+
1145
+ if (p === '/api/mcp/binding') {
1146
+ if (req.method !== 'PATCH') return sendJson(res, 405, { error: `/api/mcp/binding 只支持 PATCH,收到 ${req.method}` });
1147
+ let body;
1148
+ try {
1149
+ body = await readBody(req, TASK_BODY_LIMIT);
1150
+ } catch (e) {
1151
+ return sendJson(res, e.status || 400, { error: e.message });
1152
+ }
1153
+ const mode = typeof body.mode === 'string' ? body.mode.trim() : '';
1154
+ if (mode && !['off', 'inject', 'loop'].includes(mode)) {
1155
+ return sendJson(res, 400, { error: `模式只支持 off / inject / loop,收到 ${mode}` });
1156
+ }
1157
+ if (!mode && !Array.isArray(body.serverIds)) {
1158
+ return sendJson(res, 400, { error: '没有要改的内容:给 serverIds 数组或 mode' });
1159
+ }
1160
+ const r = await patchMcpBinding({
1161
+ serverIds: Array.isArray(body.serverIds) ? body.serverIds : undefined,
1162
+ mode: mode || undefined,
1163
+ });
1164
+ if (!r.ok) return mcpFail(res, r);
1165
+ // 改完立刻重拉:否则「改成功了但工具数还是旧的」看起来像没生效
1166
+ await refreshMcpTools({ force: true, baseUrl: cfg.baseUrl, key: cfg.key });
1167
+ return sendJson(res, 200, { ok: true, toolCount: mcpToolList().length });
1168
+ }
1169
+
1170
+ if (p === '/api/mcp/servers') {
1171
+ if (req.method !== 'POST') return sendJson(res, 405, { error: `/api/mcp/servers 只支持 POST,收到 ${req.method}` });
1172
+ let body;
1173
+ try {
1174
+ body = await readBody(req, TASK_BODY_LIMIT);
1175
+ } catch (e) {
1176
+ return sendJson(res, e.status || 400, { error: e.message });
1177
+ }
1178
+ const headers = body.headers && typeof body.headers === 'object' ? body.headers : undefined;
1179
+ const r = await createMcpServer({ name: body.name, url: body.url, headers });
1180
+ if (!r.ok) return mcpFail(res, r);
1181
+ await refreshMcpTools({ force: true, baseUrl: cfg.baseUrl, key: cfg.key });
1182
+ return sendJson(res, 200, {
1183
+ ok: true,
1184
+ server: r.server,
1185
+ probeError: r.probeError || '',
1186
+ note: r.note || '',
1187
+ toolCount: mcpToolList().length,
1188
+ });
1189
+ }
1190
+
1191
+ if (p.startsWith('/api/mcp/servers/')) {
1192
+ if (req.method !== 'DELETE') return sendJson(res, 405, { error: `/api/mcp/servers/<id> 只支持 DELETE,收到 ${req.method}` });
1193
+ const id = decodeURIComponent(p.slice('/api/mcp/servers/'.length));
1194
+ const r = await deleteMcpServer(id);
1195
+ if (!r.ok) return mcpFail(res, r);
1196
+ await refreshMcpTools({ force: true, baseUrl: cfg.baseUrl, key: cfg.key });
1197
+ return sendJson(res, 200, { ok: true, message: r.message, note: r.note || '', toolCount: mcpToolList().length });
1198
+ }
1199
+
1200
+ return sendJson(res, 404, { error: `未知的 MCP 接口:${p}` });
1201
+ }
1202
+
1203
+ /* ---------- 命令行会话(模式六)的只读存档 ----------
1204
+ * 由来(实测踩到):终端 `gateway-agent -i` 的对话落在 `<数据根>/sessions/`,而网页侧从来不读它,
1205
+ * 用户会以为「记录丢了」。这里补一个**只读**入口:列表给元信息、单条给完整消息供回放;
1206
+ * 不写、不改、不删(列表时的 TTL 清理沿用 CLI 本身的口径,与 `/api/tasks` 的 prune 同理)。
1207
+ * 目录用 `resolveSessionDir()`,所以 `--session-dir` / `LLM_GATEWAY_DATA_DIR` 与命令行落在同一处。
1208
+ */
1209
+
1210
+ /** 懒解析 + 可注入(测试直接塞一个 store);`null` = 目录不可用,接口据此回 503 */
1211
+ function cliSessionStore(cfg) {
1212
+ if (cfg.cliSessions !== undefined) return cfg.cliSessions;
1213
+ try {
1214
+ cfg.cliSessions = createSessionStore(resolveSessionDir('', ROOT_DIR).dir);
1215
+ } catch {
1216
+ cfg.cliSessions = null;
1217
+ }
1218
+ return cfg.cliSessions;
1219
+ }
1220
+
1221
+ /** 列表用的形状:**不带 messages**(一条会话可以几百 KB,列表不该拖着正文走) */
1222
+ function cliSessionMeta(rec) {
1223
+ const messages = Array.isArray(rec.messages) ? rec.messages : [];
1224
+ return {
1225
+ id: rec.id,
1226
+ workingDir: rec.workingDir || '',
1227
+ model: rec.model || '',
1228
+ mode: rec.mode || 'manual',
1229
+ // 注意字段名:这里是**条数**,正文只出现在 `/api/cli-sessions/<id>` 的 `session.messages` 里
1230
+ messageCount: messages.length,
1231
+ userTurns: messages.filter((m) => m && m.role === 'user').length,
1232
+ usage: { prompt: Number(rec.usage?.prompt) || 0, completion: Number(rec.usage?.completion) || 0 },
1233
+ pending: Boolean(rec.pending),
1234
+ touchedAt: Number(rec.touchedAt) || 0,
1235
+ };
1236
+ }
1237
+
1238
+ async function handleCliSessions(req, res, cfg, p) {
1239
+ const store = cliSessionStore(cfg);
1240
+ if (!store) return sendJson(res, 503, { error: '命令行会话目录不可用(主目录与临时目录都不可写)' });
1241
+
1242
+ if (p === '/api/cli-sessions') {
1243
+ if (req.method !== 'GET') return sendJson(res, 405, { error: `/api/cli-sessions 只支持 GET,收到 ${req.method}` });
1244
+ return sendJson(res, 200, {
1245
+ dir: store.dir,
1246
+ ttlDays: SESSION_TTL_DAYS,
1247
+ maxSessions: MAX_SESSIONS,
1248
+ sessions: store.list(SESSION_TTL_MS).map(cliSessionMeta),
1249
+ });
1250
+ }
1251
+
1252
+ const id = decodeURIComponent(p.slice('/api/cli-sessions/'.length));
1253
+ if (req.method !== 'GET') {
1254
+ return sendJson(res, 405, { error: `/api/cli-sessions/<id> 只支持 GET,收到 ${req.method}` });
1255
+ }
1256
+ if (!isSafeId(id)) return sendJson(res, 400, { error: '会话 id 必须是 UUID(这也是防目录穿越的那道校验)' });
1257
+ const rec = store.read(id);
1258
+ if (!rec) return sendJson(res, 404, { error: `没有这条命令行会话:${id}(可能已被滑动 TTL 清理)` });
1259
+ return sendJson(res, 200, {
1260
+ session: {
1261
+ ...cliSessionMeta(rec),
1262
+ // system 在 messages[0] 里(CLI 就是这么落的),页面自己决定要不要展开
1263
+ messages: Array.isArray(rec.messages) ? rec.messages : [],
1264
+ resumeCommand: `gateway-agent -i --resume ${rec.id}`,
1265
+ resumeCommandSource: `node cli-agent.js -i --resume ${rec.id}`,
1266
+ },
1267
+ });
1268
+ }
1269
+
1000
1270
  /** 任务数据存到磁盘后,服务端就是唯一真相;这里把增删改查都收口 */
1001
1271
  async function handleStore(req, res, cfg, p) {
1002
1272
  const store = cfg.store;
@@ -1290,6 +1560,22 @@ export function createHubServer(cfg) {
1290
1560
  }
1291
1561
  }
1292
1562
 
1563
+ // 网页版 MCP 面板:能改网关侧的绑定/登记,且回传服务器清单 → 与任务记录同级别看待
1564
+ if (p === '/api/mcp' || p.startsWith('/api/mcp/')) {
1565
+ if (!fsAllowed()) {
1566
+ return sendJson(res, 403, { error: 'MCP 面板已禁用:服务未绑定在本机地址(可用 --allow-remote-fs 放行)' });
1567
+ }
1568
+ return await handleMcpPanel(req, res, cfg, p);
1569
+ }
1570
+
1571
+ // 命令行会话的存档里是完整对话正文与本机路径,与任务记录同级别看待
1572
+ if (p === '/api/cli-sessions' || p.startsWith('/api/cli-sessions/')) {
1573
+ if (!fsAllowed()) {
1574
+ return sendJson(res, 403, { error: '命令行会话已禁用:服务未绑定在本机地址(可用 --allow-remote-fs 放行)' });
1575
+ }
1576
+ return await handleCliSessions(req, res, cfg, p);
1577
+ }
1578
+
1293
1579
  // 任务记录里含工具读过的文件正文,属于本机数据:和目录浏览一样只对本机开放
1294
1580
  if (p === '/api/store' || p === '/api/tasks' || p.startsWith('/api/tasks/')) {
1295
1581
  if (!fsAllowed()) {
@@ -1339,8 +1625,11 @@ export function createHubServer(cfg) {
1339
1625
 
1340
1626
  export function runHub(argv, { entry = 'web' } = {}) {
1341
1627
  const args = parseArgs(argv, VALUE_FLAGS, BOOL_FLAGS);
1628
+ // 语言必须在**任何输出之前**定下来:`--help`、参数错误、启动横幅都要按同一语言输出。
1629
+ // 默认仍是 zh(没给 --lang / LLM_GATEWAY_LANG 时一个字符都不变)。
1630
+ setLang(resolveLang(args, process.env));
1342
1631
  if (args.help) {
1343
- console.log(HELP);
1632
+ console.log(helpText());
1344
1633
  return null;
1345
1634
  }
1346
1635
 
@@ -1350,11 +1639,11 @@ export function runHub(argv, { entry = 'web' } = {}) {
1350
1639
  const secret = resolveSecretKey({ args, env: process.env, file: secretFile });
1351
1640
  const key = secret.key;
1352
1641
  const keyFromEnv = Boolean(resolveKey(args));
1353
- for (const w of [secret.warning].filter(Boolean)) console.warn(`[警告] ${w}`);
1642
+ for (const w of [secret.warning].filter(Boolean)) console.warn(pick(`[警告] ${w}`, `[warn] ${w}`));
1354
1643
  // 配置的唯一解析口径(lib/settings.js):--flag > 环境变量 > config.json > 内置默认
1355
1644
  const settingsFile = settingsFilePath();
1356
1645
  const settings = resolveSettings({ file: settingsFile, env: process.env, args });
1357
- for (const w of settings.warnings) console.warn(`[警告] ${w}`);
1646
+ for (const w of settings.warnings) console.warn(pick(`[警告] ${w}`, `[warn] ${w}`));
1358
1647
  // 网关地址与模型:事实来源是 lib/settings.js 的四层优先级(flag > 环境变量/.env > config.json > 内置默认)。
1359
1648
  // 这里曾经用 resolveBaseUrl(只认 flag/env),于是**横幅把 config.json 里的地址显示成内置默认**,
1360
1649
  // 而 applySettings 之后的运行态用的却是配置文件里的值 —— 横幅与事实打架,用户以为配置没生效。
@@ -1371,23 +1660,44 @@ export function runHub(argv, { entry = 'web' } = {}) {
1371
1660
  // 没有密钥**不再退出**:以前这里 exit(1),用户连界面都进不去,只能去手写 .env。
1372
1661
  // 现在照常起服务,页面能打开、设置面板能填密钥 —— 缺密钥的请求由各接口回 409 needsKey。
1373
1662
  if (!key) {
1374
- console.warn('[警告] 还没有配置密钥:页面能打开,但聊天与任务会提示缺少密钥。');
1375
- console.warn(' 三种给法(任选其一):');
1376
- console.warn(' ① 在页面「设置」里填(存到 credentials.json,仅本用户可读)');
1377
- console.warn(' ② gateway-agent config set key sk-xxx');
1378
- console.warn(' 环境变量 GATEWAY_KEY / SK,或启动参数 --key sk-xxx');
1663
+ console.warn(pick(
1664
+ '[警告] 还没有配置密钥:页面能打开,但聊天与任务会提示缺少密钥。',
1665
+ '[warn] no key configured yet: the pages open, but chat and tasks will report a missing key.',
1666
+ ));
1667
+ console.warn(pick(' 三种给法(任选其一):', ' three ways to set it (pick one):'));
1668
+ console.warn(pick(
1669
+ ' ① 在页面「设置」里填(存到 credentials.json,仅本用户可读)',
1670
+ ' 1) fill it in the page\'s "Settings" (stored in credentials.json, readable by this user only)',
1671
+ ));
1672
+ console.warn(pick(
1673
+ ' ② gateway-agent config set key sk-xxx',
1674
+ ' 2) gateway-agent config set key sk-xxx',
1675
+ ));
1676
+ console.warn(pick(
1677
+ ' ③ 环境变量 GATEWAY_KEY / SK,或启动参数 --key sk-xxx',
1678
+ ' 3) env GATEWAY_KEY / SK, or the --key sk-xxx flag',
1679
+ ));
1379
1680
  }
1380
1681
  if (port === null) {
1381
- console.error(`[错误] 端口非法:${strArg(args.port) || process.env.WEB_PORT || process.env.TASK_WEB_PORT}`);
1682
+ console.error(pick(
1683
+ `[错误] 端口非法:${strArg(args.port) || process.env.WEB_PORT || process.env.TASK_WEB_PORT}`,
1684
+ `[error] invalid port: ${strArg(args.port) || process.env.WEB_PORT || process.env.TASK_WEB_PORT}`,
1685
+ ));
1382
1686
  process.exit(1);
1383
1687
  }
1384
1688
 
1385
1689
  const loopback = isLoopbackHost(host);
1386
1690
  const fsEnabled = loopback || Boolean(args['allow-remote-fs']);
1387
1691
  if (!loopback && !fsEnabled) {
1388
- console.warn(`[警告] 绑定在 ${host} 且未加 --allow-remote-fs:目录浏览与任务记录接口将返回 403。`);
1692
+ console.warn(pick(
1693
+ `[警告] 绑定在 ${host} 且未加 --allow-remote-fs:目录浏览与任务记录接口将返回 403。`,
1694
+ `[warn] bound to ${host} without --allow-remote-fs: directory browsing and task-record APIs will return 403.`,
1695
+ ));
1389
1696
  } else if (!loopback) {
1390
- console.warn('[警告] 已用 --allow-remote-fs 开放目录浏览与任务记录,任何能访问该端口的人都能浏览本机目录、读取任务历史。');
1697
+ console.warn(pick(
1698
+ '[警告] 已用 --allow-remote-fs 开放目录浏览与任务记录,任何能访问该端口的人都能浏览本机目录、读取任务历史。',
1699
+ '[warn] --allow-remote-fs is on: directory browsing and task records are open, so anyone who can reach this port can browse local directories and read task history.',
1700
+ ));
1391
1701
  }
1392
1702
 
1393
1703
  // bash 与 --allow-remote-fs 是同一性质:显式、危险、默认关。
@@ -1396,10 +1706,13 @@ export function runHub(argv, { entry = 'web' } = {}) {
1396
1706
  // 只能反复说已静态复核」的根因:不是权限被拒,是这支笔从没发给过它。
1397
1707
  setBashEnabled(Boolean(args['allow-bash']));
1398
1708
  if (isBashEnabled()) {
1399
- console.warn(
1709
+ console.warn(pick(
1400
1710
  '[警告] 已用 --allow-bash 开启命令执行:任务页「自动」模式下模型跑命令不会再逐个问你'
1401
1711
  + '(手动模式仍会挂起等你批准)。命令仍在工作目录内执行,风险请自行评估。',
1402
- );
1712
+ '[warn] --allow-bash enables command execution: on the task page, "auto" mode no longer asks you'
1713
+ + ' before every command (manual mode still waits for your approval). Commands still run inside the'
1714
+ + ' working directory; judge the risk yourself.',
1715
+ ));
1403
1716
  }
1404
1717
 
1405
1718
  // 任务数据落盘。即使只用聊天页也要初始化 —— 任务页随时能打开
@@ -1408,19 +1721,20 @@ export function runHub(argv, { entry = 'web' } = {}) {
1408
1721
  // args/env 已经被 settings 合并过了(flag > env > config.json),这里再传一次也无妨
1409
1722
  resolved = resolveStoreDir(strArg(settings.values['store.dir']) || args.store || null, ROOT_DIR);
1410
1723
  } catch (e) {
1411
- console.error(`[错误] ${e.message}`);
1724
+ // e.message 来自 lib/taskstore.js(中文):英文分支里它按 pick 的回落口径原样保留
1725
+ console.error(pick(`[错误] ${e.message}`, `[error] ${e.message}`));
1412
1726
  process.exit(1);
1413
1727
  }
1414
1728
  let store;
1415
1729
  try {
1416
1730
  store = new TaskStore(resolved.dir).init();
1417
1731
  } catch (e) {
1418
- console.error(`[错误] 任务存储初始化失败:${e.message}`);
1732
+ console.error(pick(`[错误] 任务存储初始化失败:${e.message}`, `[error] failed to initialize task storage: ${e.message}`));
1419
1733
  process.exit(1);
1420
1734
  }
1421
- if (resolved.notice) console.log(`[提示] ${resolved.notice}`);
1422
- if (resolved.warning) console.warn(`[警告] ${resolved.warning}`);
1423
- for (const w of store.warnings) console.warn(`[警告] ${w}`);
1735
+ if (resolved.notice) console.log(pick(`[提示] ${resolved.notice}`, `[info] ${resolved.notice}`));
1736
+ if (resolved.warning) console.warn(pick(`[警告] ${resolved.warning}`, `[warn] ${resolved.warning}`));
1737
+ for (const w of store.warnings) console.warn(pick(`[警告] ${w}`, `[warn] ${w}`));
1424
1738
 
1425
1739
  const mode = normalizeMode(args.mode || process.env.TASK_MODE || 'manual');
1426
1740
  // 挂起态落盘:刷新页面、甚至重启服务后,待批准的写入都还在
@@ -1460,48 +1774,126 @@ export function runHub(argv, { entry = 'web' } = {}) {
1460
1774
  sessionLimits(cfg),
1461
1775
  );
1462
1776
  const orphans = sweepOrphanSessions(cfg);
1777
+ // 命令行会话(模式六)的只读存档:与 cli-agent 同一处落盘。目录不可写不算致命 ——
1778
+ // 服务照起,/api/cli-sessions 回 503 说明原因(存档本来就不是服务写的)
1779
+ try {
1780
+ cfg.cliSessions = createSessionStore(resolveSessionDir('', ROOT_DIR).dir);
1781
+ } catch (e) {
1782
+ cfg.cliSessions = null;
1783
+ console.warn(pick(
1784
+ `[警告] 命令行会话目录不可用(${e.message}):网页里的「命令行会话」只读页会显示不可用`,
1785
+ `[warn] CLI session directory unavailable (${e.message}); the read-only "CLI sessions" page will report it`,
1786
+ ));
1787
+ }
1463
1788
  const server = createHubServer(cfg);
1464
1789
 
1465
1790
  server.on('error', (e) => {
1466
- if (e.code === 'EADDRINUSE') console.error(`[错误] 端口 ${port} 已被占用:换一个 --port,或先关掉占用进程`);
1467
- else console.error(`[错误] 服务启动失败:${e.message}`);
1791
+ if (e.code === 'EADDRINUSE') {
1792
+ console.error(pick(
1793
+ `[错误] 端口 ${port} 已被占用:换一个 --port,或先关掉占用进程`,
1794
+ `[error] port ${port} is already in use: pick another --port, or stop the process holding it`,
1795
+ ));
1796
+ } else {
1797
+ console.error(pick(`[错误] 服务启动失败:${e.message}`, `[error] failed to start the service: ${e.message}`));
1798
+ }
1468
1799
  process.exit(1);
1469
1800
  });
1470
1801
 
1471
1802
  server.listen(port, host, () => {
1472
1803
  const s = store.stats();
1473
- console.log('LLM API Gateway · 本地 Web UI');
1474
- console.log(` 页面 http://${host}:${port}/(聊天 / 任务 / 手册,顶部 Tab 切换)`);
1475
- console.log(` 面板 /chat 聊天 · /task 任务 · /manual 手册(都能单独打开)`);
1804
+ console.log(pick('LLM API Gateway · 本地 Web UI', 'LLM API Gateway · Local Web UI'));
1805
+ console.log(pick(
1806
+ ` 页面 http://${host}:${port}/(聊天 / 任务 / 手册,顶部 Tab 切换)`,
1807
+ ` Page http://${host}:${port}/ (chat / task / manual, switch with the tabs on top)`,
1808
+ ));
1809
+ console.log(pick(
1810
+ ' 面板 /chat 聊天 · /task 任务 · /manual 手册(都能单独打开)',
1811
+ ' Panels /chat chat · /task task · /manual manual (each opens on its own)',
1812
+ ));
1813
+ console.log(pick(
1814
+ ` 命令行 http://${host}:${port}/sessions 命令行会话的只读存档(终端 gateway-agent -i 聊过的对话)`,
1815
+ ` CLI http://${host}:${port}/sessions read-only archive of terminal gateway-agent -i conversations`,
1816
+ ));
1476
1817
  // 网关 / 模型也要带来源:新机器上「有值」不等于「配过」——默认值、当前目录的 .env、
1477
1818
  // config.json 三者长得很像,不说清来源就只能靠猜(与 CLI 横幅同一套文案)
1478
- console.log(` 网关 ${baseUrl}(${settingSourceText(settings, 'baseUrl')})`);
1479
- console.log(` 模型 ${model}(${settingSourceText(settings, 'model')})`);
1819
+ console.log(pick(
1820
+ ` 网关 ${baseUrl}(${settingSourceText(settings, 'baseUrl')})`,
1821
+ ` Gateway ${baseUrl} (${settingSourceText(settings, 'baseUrl')})`,
1822
+ ));
1823
+ console.log(pick(
1824
+ ` 模型 ${model}(${settingSourceText(settings, 'model')})`,
1825
+ ` Model ${model} (${settingSourceText(settings, 'model')})`,
1826
+ ));
1480
1827
  // 密钥一行要能回答「配没配、从哪来」——来源是环境时,credentials.json 不参与,这一点要说清
1481
- console.log(` 密钥 ${key ? `${maskKey(key)}(${secretSourceText(secret)})` : '未配置'}`);
1828
+ console.log(pick(
1829
+ ` 密钥 ${key ? `${maskKey(key)}(${secretSourceText(secret)})` : '未配置'}`,
1830
+ ` Key ${key ? `${maskKey(key)} (${secretSourceText(secret)})` : 'not configured'}`,
1831
+ ));
1482
1832
  if (!key) {
1483
1833
  const srcInfo = summarizeConfigSources({ keys: ['baseUrl', 'model'], env: cfg.env, args });
1484
1834
  if (srcInfo.allDefault && !srcInfo.configExists) {
1485
- console.log(' 这台机器还没有任何配置:上面的网关与模型是**内置默认值**,不是「已经配好了」');
1835
+ console.log(pick(
1836
+ ' 这台机器还没有任何配置:上面的网关与模型是**内置默认值**,不是「已经配好了」',
1837
+ ' this machine has no config at all yet: the gateway and model above are **built-in defaults**, not "already configured"',
1838
+ ));
1486
1839
  }
1487
- console.log(' 填法:页面「设置」里填(存 credentials.json,仅本用户可读)');
1488
- console.log(' gateway-agent config set key sk-xxx;或环境变量 GATEWAY_KEY / --key');
1840
+ console.log(pick(
1841
+ ' 填法:页面「设置」里填(存 credentials.json,仅本用户可读)',
1842
+ ' how to set it: the page\'s "Settings" (stored in credentials.json, readable by this user only)',
1843
+ ));
1844
+ console.log(pick(
1845
+ ' 或 gateway-agent config set key sk-xxx;或环境变量 GATEWAY_KEY / --key',
1846
+ ' or gateway-agent config set key sk-xxx; or env GATEWAY_KEY / --key',
1847
+ ));
1489
1848
  }
1490
- console.log(` 工具 ${availableTools().map((t) => t.function.name).join(' / ')}(计划模式只给只读工具)`);
1491
- console.log(` 模式 默认 ${MODE_LABELS[mode]}(${mode})· 任务页可随时切换`);
1492
- console.log(` 存储 ${s.dir}`);
1493
- console.log(` (${STORE_SOURCE_TEXT[resolved.source] || resolved.source},当前 ${s.taskCount}/${s.maxTasks} 条,滑动保留 ${s.ttlDays} 天)`);
1849
+ console.log(pick(
1850
+ ` 工具 ${availableTools().map((t) => t.function.name).join(' / ')}(计划模式只给只读工具)`,
1851
+ ` Tools ${availableTools().map((t) => t.function.name).join(' / ')} (plan mode gets read-only tools only)`,
1852
+ ));
1853
+ console.log(pick(
1854
+ ` 模式 默认 ${MODE_LABELS[mode]}(${mode})· 任务页可随时切换`,
1855
+ // 英文分支直接用模式 id:MODE_LABELS 是中文标签,且 /api/config 也下发它(契约不能动)
1856
+ ` Mode default ${mode} · switchable any time on the task page`,
1857
+ ));
1858
+ console.log(pick(` 存储 ${s.dir}`, ` Store ${s.dir}`));
1859
+ console.log(pick(
1860
+ ` (${storeSourceText(resolved.source)},当前 ${s.taskCount}/${s.maxTasks} 条,滑动保留 ${s.ttlDays} 天)`,
1861
+ ` (${storeSourceText(resolved.source)}; ${s.taskCount}/${s.maxTasks} tasks now, sliding retention ${s.ttlDays} days)`,
1862
+ ));
1494
1863
  const sess = cfg.taskSessions.stats();
1495
- console.log(` 会话 ${sess.count} 条 / ${(sess.bytes / 1024).toFixed(0)}KB(模型态消息,让模型不必重复读文件)`);
1496
- if (orphans) console.log(` 启动时清掉了 ${orphans} 个没有任务的孤儿会话`);
1497
- console.log(` 配置 ${settingsFile}${settings.exists ? '' : '(还没有,用默认值;gateway-agent config set 可改)'}`);
1498
- console.log(` 密钥文件 ${secretFile}${secret.source === 'file' ? '' : '(当前没用它:密钥来自环境变量或启动参数)'}`);
1499
- console.log(' 停止 Ctrl+C');
1864
+ console.log(pick(
1865
+ ` 会话 ${sess.count} / ${(sess.bytes / 1024).toFixed(0)}KB(模型态消息,让模型不必重复读文件)`,
1866
+ ` Sessions ${sess.count} / ${(sess.bytes / 1024).toFixed(0)}KB (model-state messages, so the model need not re-read files)`,
1867
+ ));
1868
+ if (orphans) {
1869
+ console.log(pick(
1870
+ ` 启动时清掉了 ${orphans} 个没有任务的孤儿会话`,
1871
+ ` cleaned up ${orphans} orphan session(s) that had no task at startup`,
1872
+ ));
1873
+ }
1874
+ console.log(pick(
1875
+ ` 配置 ${settingsFile}${settings.exists ? '' : '(还没有,用默认值;gateway-agent config set 可改)'}`,
1876
+ ` Config ${settingsFile}${settings.exists ? '' : ' (not there yet, using the defaults; gateway-agent config set can change it)'}`,
1877
+ ));
1878
+ console.log(pick(
1879
+ ` 密钥文件 ${secretFile}${secret.source === 'file' ? '' : '(当前没用它:密钥来自环境变量或启动参数)'}`,
1880
+ ` Key file ${secretFile}${secret.source === 'file' ? '' : ' (not used right now: the key comes from env or a startup flag)'}`,
1881
+ ));
1882
+ console.log(pick(' 停止 Ctrl+C', ' Stop Ctrl+C'));
1500
1883
  console.log('');
1501
- console.log(` 轮次 单个任务最多 ${cfg.maxSteps} 轮模型调用(--max-steps 可调);撞到上限会先收尾给结论,再给「继续执行」`);
1502
- console.log(' 写入按当前模式处理:手动逐个批准 / 自动直接执行 / 计划只读先出方案。');
1884
+ console.log(pick(
1885
+ ` 轮次 单个任务最多 ${cfg.maxSteps} 轮模型调用(--max-steps 可调);撞到上限会先收尾给结论,再给「继续执行」`,
1886
+ ` Turns at most ${cfg.maxSteps} model calls per task (tune with --max-steps); hitting the limit wraps up with a conclusion first, then offers "continue"`,
1887
+ ));
1888
+ console.log(pick(
1889
+ ' 写入按当前模式处理:手动逐个批准 / 自动直接执行 / 计划只读先出方案。',
1890
+ ' writes follow the current mode: manual approves one by one / auto runs directly / plan stays read-only and proposes first.',
1891
+ ));
1503
1892
  if (entry === 'task') {
1504
- console.log(' 提示 / 是 Tab 外壳(聊天 / 任务 / 手册同页切换),/task 直接落到任务面板。');
1893
+ console.log(pick(
1894
+ ' 提示 / 是 Tab 外壳(聊天 / 任务 / 手册同页切换),/task 直接落到任务面板。',
1895
+ ' Note / is the tab shell (chat / task / manual on one page); /task drops straight into the task panel.',
1896
+ ));
1505
1897
  }
1506
1898
  });
1507
1899