llm-api-gateway-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
package/lib/hub.js ADDED
@@ -0,0 +1,1494 @@
1
+ /**
2
+ * 统一的本地 Web 服务(模式四 聊天 + 模式五 任务,同一个端口)
3
+ *
4
+ * 原来聊天和任务各是一个进程、各占一个端口(3100 / 3101),两个页面之间靠写死端口号互相跳转。
5
+ * 现在合成一个服务:
6
+ * / → 聊天页(模式四)
7
+ * /task → 任务页(模式五)
8
+ * 两页共用一套静态资源与 /api/config、/api/models,各自的业务接口按前缀分发。
9
+ *
10
+ * 这样只需要起一个进程、开一个端口,页面之间的跳转也变成同源相对路径。
11
+ */
12
+
13
+ import http from 'node:http';
14
+ import path from 'node:path';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { existsSync, statSync, createReadStream } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import {
20
+ DEFAULT_BASE_URL,
21
+ loadDotEnv,
22
+ parseArgs,
23
+ strArg,
24
+ resolveKey,
25
+ resolveBaseUrl,
26
+ resolvePort,
27
+ sendJson,
28
+ readBody,
29
+ maskKey,
30
+ errorText,
31
+ openSse,
32
+ isLoopbackHost,
33
+ resolveStatic,
34
+ sessionBaggage,
35
+ } from './common.js';
36
+ import { availableTools, checkWorkDir, browseDir, listRoots, describeCall, setBashEnabled, isBashEnabled } from './tools.js';
37
+ // MCP 工具清单按**当次密钥**从网关取:页面上的工具列表与任务运行共用这一份缓存
38
+ import { refreshMcpTools } from './mcp.js';
39
+ import { readMemory, initInstructions } from './memory.js';
40
+ import {
41
+ runAgent,
42
+ resumeWithApproval,
43
+ saveRun,
44
+ takeRun,
45
+ dropRun,
46
+ runStats,
47
+ listRuns,
48
+ setRunPersistence,
49
+ normalizeMode,
50
+ toolsForMode,
51
+ MODES,
52
+ MODE_LABELS,
53
+ AGENT_LIMITS,
54
+ resolveMaxSteps,
55
+ } from './agent.js';
56
+ import { buildTaskSystemPrompt, pushUser } from './runner.js';
57
+ import { savePlanDoc, lastAssistantText, firstUserText } from './plandoc.js';
58
+ import { TaskStore, resolveStoreDir, isSafeId, TTL_MS as TASK_TTL_MS } from './taskstore.js';
59
+ import { createRunStore } from './runstore.js';
60
+ import {
61
+ createTaskSessionStore,
62
+ loadOrCreateTaskSession,
63
+ prepareTaskSession,
64
+ refreshSessionContext,
65
+ TASK_SESSION_MAX_BYTES,
66
+ HISTORY_MAX_CHARS,
67
+ HISTORY_KEEP_RECENT_TURNS,
68
+ HISTORY_READ_FILE_DIGEST,
69
+ } from './tasksession.js';
70
+ import {
71
+ resolveSettings,
72
+ writeSettings,
73
+ settingsFilePath,
74
+ listSettings,
75
+ restartKeysIn,
76
+ isSettingKey,
77
+ isUiStateKey,
78
+ isSecretKey,
79
+ parseSettingValue,
80
+ unknownKeyError,
81
+ } from './settings.js';
82
+ import { commandRows } from './commands.js';
83
+ import { estimateCost, formatCost } from './pricing.js';
84
+ /**
85
+ * 密钥的唯一实现:环境里没给密钥时,兜底读 `<数据根>/credentials.json`。
86
+ * 密钥**不进 config.json**(那条红线没变),界面 / CLI 配的就是这个文件。
87
+ */
88
+ import {
89
+ keyRow,
90
+ readSecret,
91
+ resolveSecretKey,
92
+ secretFilePath,
93
+ secretSourceText,
94
+ writeSecret,
95
+ clearSecret,
96
+ } from './secrets.js';
97
+ /**
98
+ * 任务页的指令表(public/task-slash.js)是给浏览器用的经典脚本,`import` 它的**副作用**就是把表挂到
99
+ * `globalThis.TaskSlash`(模块里那句 `root.TaskSlash = factory()`;测试 tests/task-slash.test.mjs 用的是同一手法)。
100
+ * 这样 `/api/commands`、任务页面板、手册页三处读的是**同一张表** —— 手册再也不会"少几条"。
101
+ */
102
+ import '../public/task-slash.js';
103
+
104
+ const taskSlash = globalThis.TaskSlash;
105
+
106
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
107
+ // 本文件在 lib/ 下,而 .env、public/ 以及存储目录的兜底位置都以项目根为基准
108
+ const ROOT_DIR = path.join(SCRIPT_DIR, '..');
109
+ const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
110
+ const DEFAULT_PORT = 3100;
111
+ // 任务消息里会带上工具读过的文件正文,可能很大;比默认的 8MB 放宽
112
+ const TASK_BODY_LIMIT = 32 * 1024 * 1024;
113
+ // 先加载 .env,再算默认模型 —— 否则 GATEWAY_MODEL 赶不上参与默认值计算
114
+ loadDotEnv(ROOT_DIR);
115
+
116
+ const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
117
+
118
+ /** 存储目录是怎么选出来的,启动时打给人看 */
119
+ const STORE_SOURCE_TEXT = {
120
+ explicit: '--store 指定',
121
+ 'user-data': '用户主目录下的 .llm-api-gateway-cli',
122
+ legacy: '旧位置(主目录不可写,见下面的警告)',
123
+ temp: '主目录不可写,退到系统临时目录',
124
+ };
125
+
126
+ const HELP = `用法:node server.js [选项]
127
+
128
+ 本地 Web UI:聊天(模式四)与任务(模式五)跑在同一个端口上。
129
+
130
+ http://127.0.0.1:${DEFAULT_PORT}/ 聊天
131
+ http://127.0.0.1:${DEFAULT_PORT}/task 任务(选目录,模型真读写)
132
+
133
+ 选项:
134
+ --port <端口> 监听端口(默认 ${DEFAULT_PORT},也可用 WEB_PORT / TASK_WEB_PORT)
135
+ --host <地址> 监听地址(默认 127.0.0.1,仅本机可访问)
136
+ -m, --model <模型> 默认模型(默认 ${DEFAULT_MODEL})
137
+ -k, --key <sk-密钥> 网关密钥(也可用环境变量 SK/GATEWAY_KEY 或 .env)
138
+ --base-url <地址> 网关地址(默认 ${DEFAULT_BASE_URL})
139
+ --store <目录> 任务数据存储目录(默认用户主目录下的 .llm-api-gateway-cli,也可用 TASK_STORE_DIR)
140
+ --mode <模式> 任务默认审批模式:manual 手动 / auto 自动 / plan 计划(默认 manual)
141
+ --max-steps <轮数> 单个任务最多几轮模型调用(默认 ${AGENT_LIMITS.MAX_STEPS},也可用 TASK_MAX_STEPS)
142
+ --allow-remote-fs 允许非本机访问时开放目录浏览与任务记录接口(危险)
143
+ --allow-bash 开启 bash 工具,让任务页的模型能真的执行命令(默认关闭;危险)
144
+ --verbose 打印每个请求的转发日志
145
+ -h, --help 显示帮助
146
+
147
+ 三种审批模式(任务页可随时切换):
148
+ manual 每次写入都要你在界面上点确认(默认)
149
+ auto 写入直接执行,不再打断你;界面仍逐条显示改了什么
150
+ (加了 --allow-bash 时命令也走这条路:不再逐个确认)
151
+ plan 只给模型只读工具,先出计划、不动文件;确认后再执行
152
+
153
+ 单个任务默认最多 ${AGENT_LIMITS.MAX_STEPS} 轮模型调用(--max-steps / TASK_MAX_STEPS 可调)。撞到上限不会「干到一半就停」:先自动走一轮不带工具的收尾把结论给你,页面再给一个「继续执行」按钮接着干。待批准的挂起态保留 ${AGENT_LIMITS.APPROVAL_TTL_MS / 60000} 分钟、最多 ${AGENT_LIMITS.MAX_RUNS} 个,且会落盘 —— 刷新页面或重启服务后仍可批准。
154
+ `;
155
+
156
+ const VALUE_FLAGS = {
157
+ '--port': 'port',
158
+ '--host': 'host',
159
+ '--model': 'model',
160
+ '-m': 'model',
161
+ '--key': 'key',
162
+ '-k': 'key',
163
+ '--base-url': 'base-url',
164
+ '--store': 'store',
165
+ '--mode': 'mode',
166
+ '--max-steps': 'max-steps',
167
+ };
168
+ const BOOL_FLAGS = { '--verbose': 'verbose', '--allow-remote-fs': 'allow-remote-fs', '--allow-bash': 'allow-bash', '--help': 'help', '-h': 'help' };
169
+
170
+ /* ---------- 静态文件 ---------- */
171
+
172
+ /**
173
+ * 正在跑的任务(进程内、不落盘)。两个用途:
174
+ * 1. 侧边栏显示「进行中 / 已完成」;
175
+ * 2. **同一条任务的互斥** —— 会话改成服务端持有之后,两个标签页同时对一条任务发指令
176
+ * 会并发写同一个会话文件。无状态时代没这个问题,有状态之后必须有。
177
+ * 丢了这个状态只是少一个标记(第 1 点),但互斥是正确性要求,不能省。
178
+ */
179
+ const runningTasks = new Map();
180
+
181
+ const MIME = {
182
+ '.html': 'text/html; charset=utf-8',
183
+ '.js': 'text/javascript; charset=utf-8',
184
+ '.css': 'text/css; charset=utf-8',
185
+ '.json': 'application/json; charset=utf-8',
186
+ '.svg': 'image/svg+xml',
187
+ '.ico': 'image/x-icon',
188
+ '.png': 'image/png',
189
+ };
190
+
191
+ /** 路径 → public/ 下的文件。每个页面各有自己的入口 */
192
+ function pageFile(urlPath) {
193
+ if (urlPath === '/' || urlPath === '/index.html') return 'index.html';
194
+ if (urlPath === '/task' || urlPath === '/task/' || urlPath === '/task.html') return 'task.html';
195
+ if (urlPath === '/manual' || urlPath === '/manual/' || urlPath === '/manual.html') return 'manual.html';
196
+ return null;
197
+ }
198
+
199
+ function serveStatic(req, res, urlPath) {
200
+ const file = resolveStatic(PUBLIC_DIR, pageFile(urlPath) ? `/${pageFile(urlPath)}` : urlPath);
201
+ if (!file) return sendJson(res, 403, { error: '禁止访问' });
202
+ if (!existsSync(file) || !statSync(file).isFile()) return sendJson(res, 404, { error: `未找到 ${urlPath}` });
203
+ res.writeHead(200, {
204
+ 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
205
+ 'cache-control': 'no-cache',
206
+ });
207
+ createReadStream(file).pipe(res);
208
+ }
209
+
210
+ /* ---------- 聊天(模式四) ---------- */
211
+
212
+ async function fetchModels(cfg) {
213
+ const resp = await fetch(`${cfg.baseUrl}/v1/models`, {
214
+ headers: { authorization: `Bearer ${cfg.key}` },
215
+ signal: AbortSignal.timeout(8000),
216
+ });
217
+ if (!resp.ok) throw Object.assign(new Error(await errorText(resp)), { status: resp.status });
218
+ const json = await resp.json();
219
+ return (json.data || []).map((m) => (typeof m === 'string' ? m : m.id)).filter(Boolean);
220
+ }
221
+
222
+ /** 组装发往网关的 OpenAI 格式请求体 */
223
+ function buildChatPayload(body, cfg) {
224
+ const messages = [];
225
+ if (typeof body.system === 'string' && body.system.trim()) messages.push({ role: 'system', content: body.system });
226
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
227
+ if (!m || typeof m.content !== 'string') continue;
228
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'system') continue;
229
+ if (!m.content) continue;
230
+ messages.push({ role: m.role, content: m.content });
231
+ }
232
+ const stream = body.stream !== false;
233
+ const payload = { model: typeof body.model === 'string' && body.model.trim() ? body.model.trim() : cfg.model, messages, stream };
234
+ if (stream) payload.stream_options = { include_usage: true };
235
+ if (Number.isFinite(body.temperature)) payload.temperature = body.temperature;
236
+ if (Number.isFinite(body.maxTokens) && body.maxTokens > 0) payload.max_tokens = body.maxTokens;
237
+ return payload;
238
+ }
239
+
240
+ /** POST /api/chat:把上游 SSE 归一化成 {type:delta|usage|done|error} 后转发给浏览器 */
241
+ async function handleChat(req, res, cfg) {
242
+ let body;
243
+ try {
244
+ body = await readBody(req);
245
+ } catch (e) {
246
+ return sendJson(res, e.status || 400, { error: e.message });
247
+ }
248
+
249
+ const payload = buildChatPayload(body, cfg);
250
+ if (!payload.messages.length) return sendJson(res, 400, { error: 'messages 为空:至少需要一条 user 消息' });
251
+
252
+ const controller = new AbortController();
253
+ req.on('close', () => controller.abort()); // 前端点「停止」或断开时,同时掐断上游请求
254
+
255
+ // 聊天页同样有「一次对话」的概念(前端 state.activeId),一并告诉网关。
256
+ // 拿不到合法 id 就不发这个头,退回网关的推断口径,功能照常。
257
+ const headers = { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` };
258
+ const baggage = sessionBaggage(body.sessionId);
259
+ if (baggage) headers.baggage = baggage;
260
+
261
+ let upstream;
262
+ try {
263
+ upstream = await fetch(`${cfg.baseUrl}/v1/chat/completions`, {
264
+ method: 'POST',
265
+ headers,
266
+ body: JSON.stringify(payload),
267
+ signal: controller.signal,
268
+ });
269
+ } catch (e) {
270
+ if (controller.signal.aborted) return;
271
+ return sendJson(res, 502, { error: `连接网关失败(${cfg.baseUrl}):${e?.message || e}` });
272
+ }
273
+
274
+ if (!upstream.ok) {
275
+ const message = await errorText(upstream);
276
+ if (cfg.verbose) console.error(`[web] 上游 ${upstream.status}: ${message.slice(0, 200)}`);
277
+ return sendJson(res, upstream.status, { error: message, status: upstream.status });
278
+ }
279
+
280
+ // 非流式:直接回一个 JSON
281
+ if (!payload.stream) {
282
+ const data = await upstream.json();
283
+ const message = data?.choices?.[0]?.message ?? {};
284
+ return sendJson(res, 200, {
285
+ text: message.content ?? '',
286
+ reasoning: message.reasoning_content ?? '', // 思考模型的思维链(网关不并入 content)
287
+ usage: data?.usage ?? null,
288
+ });
289
+ }
290
+
291
+ // 流式:逐个上游 chunk 归一化后以 SSE 推给浏览器
292
+ const send = openSse(res);
293
+ const usage = { prompt: null, completion: null, total: null };
294
+ let sawContent = false;
295
+ let sawReasoning = false;
296
+
297
+ try {
298
+ const reader = upstream.body.getReader();
299
+ const decoder = new TextDecoder();
300
+ let buf = '';
301
+ for (;;) {
302
+ const { done, value } = await reader.read();
303
+ if (done) break;
304
+ buf += decoder.decode(value, { stream: true });
305
+ const parts = buf.split('\n');
306
+ buf = parts.pop() ?? '';
307
+ for (const line of parts) {
308
+ const t = line.trim();
309
+ if (!t.startsWith('data:')) continue;
310
+ const data = t.slice(5).trim();
311
+ if (!data || data === '[DONE]') continue;
312
+ let chunk;
313
+ try {
314
+ chunk = JSON.parse(data);
315
+ } catch {
316
+ continue;
317
+ }
318
+ // 思考模型(如 deepseek-v4-*)把思维链放在 delta.reasoning_content,
319
+ // 正文要等思考结束才出现在 delta.content —— 两者都要转发,否则前端长时间空白。
320
+ const delta = chunk?.choices?.[0]?.delta;
321
+ if (delta?.reasoning_content) {
322
+ sawReasoning = true;
323
+ send({ type: 'reasoning', text: delta.reasoning_content });
324
+ }
325
+ if (delta?.content) {
326
+ sawContent = true;
327
+ send({ type: 'delta', text: delta.content });
328
+ }
329
+ if (chunk?.usage) {
330
+ usage.prompt = chunk.usage.prompt_tokens ?? null;
331
+ usage.completion = chunk.usage.completion_tokens ?? null;
332
+ usage.total = chunk.usage.total_tokens ?? null;
333
+ }
334
+ const finish = chunk?.choices?.[0]?.finish_reason;
335
+ if (finish && !sawContent) {
336
+ send({
337
+ type: 'notice',
338
+ text: sawReasoning
339
+ ? `上游只返回了思维链、未产出正文(finish_reason=${finish})—— 通常是 max_tokens 太小被思考过程耗尽,调大后再试`
340
+ : `上游结束但未返回正文(finish_reason=${finish})`,
341
+ });
342
+ }
343
+ }
344
+ }
345
+ if (usage.total !== null || usage.prompt !== null) send({ type: 'usage', usage });
346
+ send({ type: 'done' });
347
+ } catch (e) {
348
+ if (controller.signal.aborted) send({ type: 'done', aborted: true });
349
+ else send({ type: 'error', message: `读取上游流失败:${e?.message || e}` });
350
+ } finally {
351
+ res.end();
352
+ }
353
+ }
354
+
355
+ /* ---------- 任务(模式五) ---------- */
356
+
357
+ /**
358
+ * **旧格式专用**:从「前端回传的全量对白」重建上下文。
359
+ *
360
+ * 这条路径没有工具历史可言 —— 模型只能看到历轮的正文,看不到任何 `role:"tool"`,
361
+ * 于是每轮都得重新 `list_dir` / `grep`。它现在只服务兼容期的老前端与外部脚本
362
+ * (`compat.legacyTaskApi`),新前端一律走「服务端持有会话」那条路,见 `handleTask`。
363
+ */
364
+ function buildTaskMessages(body, workDir, mode) {
365
+ const messages = [{ role: 'system', content: buildTaskSystemPrompt(workDir, body.system, mode) }];
366
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
367
+ if (!m || typeof m.content !== 'string' || !m.content.trim()) continue;
368
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
369
+ messages.push({ role: m.role, content: m.content });
370
+ }
371
+ return messages;
372
+ }
373
+
374
+ /** 会话落盘:失败也不能影响这一轮的对话结果,最多是下次少一点上下文 */
375
+ function persistTaskSession(cfg, run) {
376
+ if (!run?.taskId) return null;
377
+ try {
378
+ run.touchedAt = Date.now();
379
+ return cfg.taskSessions.write(run);
380
+ } catch (e) {
381
+ if (cfg.verbose) console.error(`[task] 会话落盘失败:${e?.message || e}`);
382
+ return null;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * 会话级别的限制。都可以从配置覆盖(见 `lib/settings.js`),默认值等于原常量 ——
388
+ * 加配置不改变默认行为。
389
+ */
390
+ function sessionLimits(cfg) {
391
+ const t = cfg.taskTuning || {};
392
+ return {
393
+ maxBytes: Number(t.sessionMaxBytes) > 0 ? Number(t.sessionMaxBytes) : TASK_SESSION_MAX_BYTES,
394
+ maxMessages: Number(t.sessionMaxMessages) > 0 ? Number(t.sessionMaxMessages) : undefined,
395
+ historyMaxChars: Number(t.historyMaxChars) > 0 ? Number(t.historyMaxChars) : HISTORY_MAX_CHARS,
396
+ keepRecentTurns: Number(t.historyKeepRecentTurns) >= 0 ? Number(t.historyKeepRecentTurns) : HISTORY_KEEP_RECENT_TURNS,
397
+ // 默认开:这条清单正是「别重复读」的关键,关掉是给「模型太啰嗦」的人留的后门
398
+ readFileDigest: t.readFileDigest === undefined ? HISTORY_READ_FILE_DIGEST : Boolean(t.readFileDigest),
399
+ };
400
+ }
401
+
402
+ /** 删任务 / 淘汰过期任务时,把它名下的会话文件一并删掉,否则会变成孤儿泄漏 */
403
+ function dropTaskSessions(cfg, ids) {
404
+ let dropped = 0;
405
+ for (const id of Array.isArray(ids) ? ids : []) {
406
+ try {
407
+ if (cfg.taskSessions.drop(id)) dropped++;
408
+ } catch {
409
+ /* 单个删不掉不影响其他 */
410
+ }
411
+ }
412
+ return dropped;
413
+ }
414
+
415
+ /**
416
+ * 把解析好的配置套到运行态上。
417
+ *
418
+ * 单独抽出来是为了「改完立刻生效」:`PUT /api/settings` 之后再调一次,
419
+ * 不必重启进程(`store.dir` / `retention.*` 这类启动时就被读走的不算,
420
+ * 它们在 restartKeysIn 里标出来告诉用户)。
421
+ *
422
+ * `onlyMissing` 给「被别人构造出来的 cfg」用(测试、嵌入):那时调用方显式传的
423
+ * baseUrl / model / maxSteps 才是权威,不能拿配置文件去盖掉它。
424
+ */
425
+ function applySettings(cfg, resolved, { onlyMissing = false } = {}) {
426
+ const v = resolved.values;
427
+ cfg.settings = resolved;
428
+ const put = (field, value) => {
429
+ if (onlyMissing && cfg[field] !== undefined) return;
430
+ cfg[field] = value;
431
+ };
432
+ put('model', v.model);
433
+ // 与 resolveBaseUrl 的既有口径一致:去掉尾部斜杠
434
+ put('baseUrl', String(v.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''));
435
+ put('defaultMode', normalizeMode(v.mode));
436
+ put('maxSteps', resolveMaxSteps(v.maxSteps));
437
+ put('compatLegacyTaskApi', v.compat.legacyTaskApi !== false);
438
+ // sessionLimits() 读的就是这几个扁平键,保持形状不变
439
+ if (!onlyMissing || cfg.taskTuning === undefined) {
440
+ cfg.taskTuning = {
441
+ sessionMaxBytes: v.session.maxBytes,
442
+ historyMaxChars: v.history.maxChars,
443
+ historyKeepRecentTurns: v.history.keepRecentTurns,
444
+ readFileDigest: v.history.readFileDigest !== false,
445
+ };
446
+ }
447
+ put('uiPersist', v.ui.persist === 'browser' ? 'browser' : 'server');
448
+ return cfg;
449
+ }
450
+
451
+ /** 环境变量来源:测试/嵌入使用时可以传一个空对象,把 env 层整个关掉 */
452
+ const envOf = (cfg) => cfg.env || process.env;
453
+
454
+ /**
455
+ * 重新解析配置。
456
+ *
457
+ * - 服务端自己拥有运行态时(runHub,`settingsAuthoritative`),顺带套用上去,
458
+ * 于是「手改 config.json 后刷新页面」也能生效;
459
+ * - 被别人构造出来的 cfg(测试、嵌入)只更新快照,不动调用方显式设置的字段。
460
+ *
461
+ * 每次 GET 都重读文件是有意的:用户改了配置却没看到变化时,第一个怀疑对象就是
462
+ * 「页面上还是旧值」,而原因可能就是他手写坏了文件 —— 那要以 warning 的形式显示出来。
463
+ */
464
+ function refreshSettings(cfg) {
465
+ const next = resolveSettings({ file: cfg.settingsFile, env: envOf(cfg), args: cfg.settingsArgs || null });
466
+ if (cfg.settingsAuthoritative) applySettings(cfg, next);
467
+ else cfg.settings = next;
468
+ // 密钥:环境 / 启动参数给的时候文件不参与;否则每次重读文件 ——
469
+ // 于是「界面里填完」与「手工编辑 credentials.json 后刷新页面」两条路都立刻生效。
470
+ // 判据写成 `=== false` 而不是 `!keyFromEnv`:宿主/测试自己构造的 cfg 往往不带这个字段,
471
+ // 那种情况下**不能**因为文件里没有密钥就把调用方给的 cfg.key 抹掉。
472
+ if (cfg.secretFile && cfg.keyFromEnv === false) {
473
+ const read = readSecret({ file: cfg.secretFile, env: envOf(cfg) });
474
+ cfg.key = read.key;
475
+ if (cfg.keySource !== undefined) cfg.keySource = read.key ? 'file' : '';
476
+ if (read.warning) next.warnings = [...(next.warnings || []), read.warning];
477
+ }
478
+ return next;
479
+ }
480
+
481
+ /**
482
+ * 无密钥时的统一回执。
483
+ * 不用 401(那是「你给的凭据不对」),这里的前提是**根本还没配** —— 409 + needsKey 让页面
484
+ * 能识别出「该引导去设置面板」而不是弹一个红色错误就完事。
485
+ */
486
+ function requireKey(res, cfg) {
487
+ if (cfg.key) return false;
488
+ sendJson(res, 409, {
489
+ error:
490
+ '还没有配置密钥:打开「设置」填 sk-xxx(会存到 credentials.json,仅本用户可读),'
491
+ + '或命令行执行 gateway-agent config set key sk-xxx,也可以设环境变量 GATEWAY_KEY / SK。',
492
+ needsKey: true,
493
+ });
494
+ return true;
495
+ }
496
+
497
+ /** /api/settings 的响应体:值 + 来源 + 白名单,页面据此渲染设置面板而不用各抄一份 */
498
+ function settingsPayload(cfg) {
499
+ const r = cfg.settings || resolveSettings({ file: cfg.settingsFile, env: envOf(cfg) });
500
+ // 密钥行排在最前:它在页面上最该被先看到(没有它聊天与任务都用不了),
501
+ // 而它**不在** SETTINGS_SCHEMA 里 —— 所以永远不会被写进 config.json。
502
+ const keyEntry = keyRow({ env: envOf(cfg), file: cfg.secretFile || secretFilePath(envOf(cfg)), args: cfg.settingsArgs || null });
503
+ // 运行态为准:宿主(或测试)直接塞进 cfg.key、而文件/环境里没有时,页面不能显示「未配置」
504
+ if (cfg.key && !keyEntry.hasKey) {
505
+ keyEntry.hasKey = true;
506
+ keyEntry.mask = maskKey(cfg.key) || keyEntry.mask;
507
+ keyEntry.source = cfg.keySource || (cfg.keyFromEnv ? 'env' : 'file');
508
+ keyEntry.sourceText = secretSourceText(keyEntry.source);
509
+ }
510
+ return {
511
+ file: r.file,
512
+ exists: r.exists,
513
+ // fresh:服务端还没有配置 —— 页面据此决定要不要把浏览器里那份搬上来
514
+ fresh: !r.exists,
515
+ values: r.values,
516
+ sources: r.sources,
517
+ warnings: [...(r.warnings || []), ...[keyEntry.warning].filter(Boolean)],
518
+ uiPersist: cfg.uiPersist || 'server',
519
+ key: { hasKey: keyEntry.hasKey, mask: keyEntry.mask, file: keyEntry.file, source: keyEntry.source, sourceText: keyEntry.sourceText },
520
+ rows: [keyEntry].concat(listSettings(r).map((row) => ({
521
+ key: row.key,
522
+ value: row.value,
523
+ source: row.source,
524
+ def: row.def,
525
+ env: row.env,
526
+ flag: row.flag,
527
+ desc: row.desc,
528
+ restart: row.restart,
529
+ // 渲染所需的类型信息也一并给页面:它按 type 决定控件形状,不再自己判值类型
530
+ type: row.type,
531
+ values: row.values,
532
+ min: row.min,
533
+ max: row.max,
534
+ }))),
535
+ };
536
+ }
537
+
538
+ /** GET/PUT /api/settings —— 与 CLI 的 `gateway-agent config` 共用 lib/settings.js 的同一份白名单 */
539
+ async function handleSettings(req, res, cfg) {
540
+ if (req.method === 'GET') {
541
+ refreshSettings(cfg);
542
+ return sendJson(res, 200, settingsPayload(cfg));
543
+ }
544
+
545
+ if (req.method !== 'PUT' && req.method !== 'POST') {
546
+ return sendJson(res, 405, { error: `/api/settings 只支持 GET / PUT,收到 ${req.method}` });
547
+ }
548
+
549
+ let body;
550
+ try {
551
+ body = await readBody(req);
552
+ } catch (e) {
553
+ return sendJson(res, e.status || 400, { error: e.message });
554
+ }
555
+ // 允许 { values: {...} } 或直接给一层扁平对象;忽略未知的外层包装
556
+ const patch = body && typeof body.values === 'object' && body.values !== null ? body.values : body;
557
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
558
+ return sendJson(res, 400, { error: '请求体需要是 { "键": 值 } 形式的对象(键用点号路径,如 session.maxBytes)' });
559
+ }
560
+ // keys 在密钥分支**之后**再算:密钥要从 patch 里摘出去,摘之前算会把 'key' 当成
561
+ // 「密钥类配置项」再拒一次(同一次 PUT 里带密钥 + 别的键就会莫名其妙 400)
562
+
563
+ // 密钥走**单独一条路**:写 credentials.json(仅本用户可读)并立刻热更新,不写 config.json。
564
+ // 它必须排在「密钥红线」判断之前 —— 否则页面里那个输入框永远保存不了。
565
+ // 空串 = 清掉(页面上「清空密钥」的语义);null / 未给 = 不动。
566
+ let keyNote = null;
567
+ let keyCleared = false;
568
+ if (Object.prototype.hasOwnProperty.call(patch, 'key')) {
569
+ const raw = patch.key;
570
+ delete patch.key;
571
+ if (raw === null || (typeof raw === 'string' && raw.trim() === '')) {
572
+ // 环境变量给的密钥不受这里影响:那种情况下文件本来就只是摆设
573
+ const cleared = clearSecret({ file: cfg.secretFile });
574
+ keyCleared = cleared.removed;
575
+ cfg.key = cfg.keyFromEnv ? cfg.key : '';
576
+ if (cfg.keyFromEnv) {
577
+ keyNote = '已清掉密钥文件,但当前进程的密钥来自环境变量 / --key,仍然生效。';
578
+ } else {
579
+ keyNote = cleared.removed ? '已清掉保存的密钥。' : '本来就没有保存过密钥。';
580
+ }
581
+ } else {
582
+ try {
583
+ const w = writeSecret(String(raw), { file: cfg.secretFile });
584
+ cfg.key = String(raw).trim();
585
+ cfg.keyFromEnv = false;
586
+ cfg.keySource = 'file';
587
+ keyNote = `密钥已保存到 ${w.file}${w.mode ? `(权限 ${w.mode.toString(8)})` : ''},立即生效。`
588
+ + (w.warning ? ` ${w.warning}` : '');
589
+ } catch (e) {
590
+ return sendJson(res, 400, { error: e.message, needsKey: true });
591
+ }
592
+ }
593
+ // 换了密钥,MCP 工具清单的缓存立即作废(与 /api/config 的刷新同一条路)
594
+ try {
595
+ await refreshMcpTools({ force: true, baseUrl: cfg.baseUrl, key: cfg.key });
596
+ } catch {
597
+ /* best-effort:网关没开 MCP / 断网都不该让「保存密钥」失败 */
598
+ }
599
+ if (!Object.keys(patch).length) {
600
+ refreshSettings(cfg);
601
+ return sendJson(res, 200, {
602
+ ok: true,
603
+ file: cfg.settingsFile,
604
+ applied: ['key'],
605
+ restart: [],
606
+ note: keyNote,
607
+ keyCleared,
608
+ ...settingsPayload(cfg),
609
+ });
610
+ }
611
+ }
612
+
613
+ // keys 在密钥分支**之后**再算:密钥已经从 patch 里摘掉了,这里不该再看到它
614
+ const keys = Object.keys(patch);
615
+
616
+ // 密钥放在最前面判:否则会先被报成「未知配置项」,用户看不到真正的原因
617
+ const secretKeys = keys.filter((k) => isSecretKey(k));
618
+ if (secretKeys.length) {
619
+ return sendJson(res, 400, { error: secretKeys.map((k) => parseSettingValue(k, patch[k]).error).join('\n') });
620
+ }
621
+ const unknown = keys.filter((k) => !isSettingKey(k) && !isUiStateKey(k));
622
+ if (unknown.length) {
623
+ return sendJson(res, 400, { error: unknown.map((k) => unknownKeyError(k)).join('\n') });
624
+ }
625
+
626
+ let result;
627
+ try {
628
+ result = writeSettings(patch, { file: cfg.settingsFile, allowUiState: true });
629
+ } catch (e) {
630
+ // 校验失败是 400(用户改错了);文件坏了拒绝覆盖是 409(得先去修文件)
631
+ return sendJson(res, e.validation ? 400 : 409, { error: e.message });
632
+ }
633
+
634
+ // 立刻生效(除了要重启的那几个)
635
+ refreshSettings(cfg);
636
+ const restart = restartKeysIn(result.applied);
637
+ const notes = [keyNote, restart.length ? `以下配置要重启服务才生效:${restart.join('、')}` : null].filter(Boolean);
638
+ return sendJson(res, 200, {
639
+ ok: true,
640
+ file: result.file,
641
+ applied: keyNote ? ['key', ...result.applied] : result.applied,
642
+ restart,
643
+ note: notes.length ? notes.join(' ') : null,
644
+ keyCleared,
645
+ ...settingsPayload(cfg),
646
+ });
647
+ }
648
+
649
+ /** 建好 SSE 响应头并返回 send;任务与批准两条路径共用 */
650
+ function startTaskStream(res) {
651
+ const send = openSse(res);
652
+ const controller = new AbortController();
653
+ res.on('close', () => controller.abort());
654
+ return { send, signal: controller.signal };
655
+ }
656
+
657
+ /** 一轮任务跑完后的收尾:挂起则登记运行态,最后统一发用量与结束事件 */
658
+ async function finishOutcome({ outcome, run, send, res, cfg }) {
659
+ if (outcome.status === 'awaiting-approval') {
660
+ saveRun(run);
661
+ send({
662
+ type: 'approval_required',
663
+ runId: run.id,
664
+ callId: outcome.pending.id,
665
+ name: outcome.pending.name,
666
+ label: describeCall(outcome.pending.name, outcome.pending.args),
667
+ preview: outcome.preview,
668
+ note: `待批准状态保留 ${AGENT_LIMITS.APPROVAL_TTL_MS / 60000} 分钟(已落盘,刷新页面也不会丢)`,
669
+ });
670
+ }
671
+ // 计划模式的产物必须落盘:先写文件再报「计划已就绪」,页面拿到的路径一定是真的
672
+ const saved = savePlanDocIfNeeded({ run, outcome, cfg });
673
+ if (saved) {
674
+ send({ type: 'plan_saved', path: saved.relPath, bytes: saved.bytes, action: saved.action, summary: saved.summary });
675
+ }
676
+ send({ type: 'usage_total', usage: run.usage });
677
+ // 计划模式跑完就是「计划已就绪」,页面据此给出「按计划执行」
678
+ send({
679
+ type: 'done',
680
+ status: outcome.status,
681
+ planReady: Boolean(outcome.planReady),
682
+ planFile: saved?.relPath || run.planFile || '',
683
+ hitLimit: Boolean(outcome.hitLimit),
684
+ });
685
+ res.end();
686
+ }
687
+
688
+ /**
689
+ * 计划模式收尾:把模型给出的计划写进 `<workDir>/docs/YYYYMMDD-<概要>.md`。
690
+ *
691
+ * 为什么由服务端写、而不是给模型一个「写计划」的工具:
692
+ * · 计划模式下写入工具**根本不发给模型**(见 `lib/agent.js` 的 toolsForMode),
693
+ * 为了让模型自己写而开一个写入口,那道「只读」闸门就漏了;
694
+ * · 「计划必须落盘」是硬要求,不能寄希望于模型记得调工具。
695
+ *
696
+ * 落盘失败只是「这次没落盘」:记日志照常发 done,绝不让整轮任务因为磁盘问题失败。
697
+ * 路径与命名规则见 `lib/plandoc.js`。
698
+ */
699
+ function savePlanDocIfNeeded({ run, outcome, cfg }) {
700
+ if (normalizeMode(run?.mode) !== 'plan') return null;
701
+ if (!outcome?.planReady && !outcome?.wrappedUp) return null;
702
+ const plan = lastAssistantText(run.messages);
703
+ if (!plan) return null;
704
+ try {
705
+ const saved = savePlanDoc({
706
+ workDir: run.workingDir,
707
+ plan,
708
+ // 计划里没有 `# 标题` 时拿首条用户消息兜底,总之文件名必须有概要
709
+ title: firstUserText(run.messages),
710
+ });
711
+ run.planFile = saved.relPath;
712
+ if (cfg?.verbose) console.log(`[task] 计划已落盘:${saved.relPath}(${saved.action},${saved.bytes} 字节)`);
713
+ return saved;
714
+ } catch (e) {
715
+ if (cfg?.verbose) console.error(`[task] 计划落盘失败(本轮照常结束):${e?.message || e}`);
716
+ return null;
717
+ }
718
+ }
719
+
720
+ /**
721
+ * 存储目录是否落在工作目录之内。
722
+ * 工具被沙箱限制在工作目录里,所以一旦存储目录也在里面,模型就能读到自己(以及别的任务)
723
+ * 的历史记录 —— 甚至改掉待批准调用。这不影响安全边界(本来就在授权范围内),
724
+ * 但属于「模型能看到不该看的东西」,必须提示用户。
725
+ */
726
+ function storeInsideWorkDir(storeDir, workDir) {
727
+ if (!storeDir || !workDir) return false;
728
+ const rel = path.relative(path.resolve(workDir), path.resolve(storeDir));
729
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
730
+ }
731
+
732
+ /**
733
+ * 旧格式的 `/api/task`:前端把全量对白发上来,服务端跑完即弃。
734
+ *
735
+ * 保留它是为了兼容期的老前端与外部脚本,**不是**推荐路径:这条路上服务端没有会话,
736
+ * 工具结果进不了下一轮的上下文,模型会重复读文件。响应里会带一条 notice 说清楚。
737
+ */
738
+ async function handleLegacyTask({ res, cfg, body, workDir, mode, model }) {
739
+ const messages = buildTaskMessages(body, workDir, mode);
740
+ if (messages.length < 2) return sendJson(res, 400, { error: 'messages 为空:至少需要一条 user 消息' });
741
+
742
+ const run = {
743
+ id: randomUUID(),
744
+ taskId: isSafeId(body.taskId) ? body.taskId : null,
745
+ mode,
746
+ messages,
747
+ model,
748
+ workingDir: workDir,
749
+ temperature: Number.isFinite(body.temperature) ? body.temperature : undefined,
750
+ maxTokens: Number.isFinite(body.maxTokens) && body.maxTokens > 0 ? body.maxTokens : undefined,
751
+ usage: { prompt: 0, completion: 0 },
752
+ pending: null,
753
+ touchedAt: Date.now(),
754
+ };
755
+
756
+ const { send, signal } = startTaskStream(res);
757
+ send({
758
+ type: 'started',
759
+ runId: run.id,
760
+ taskId: run.taskId,
761
+ workDir,
762
+ mode,
763
+ modeLabel: MODE_LABELS[mode],
764
+ tools: toolsForMode(mode).map((t) => t.function.name),
765
+ storeExposed: storeInsideWorkDir(cfg.store.dir, workDir),
766
+ legacy: true,
767
+ // 旧格式没有会话,也就没有跨轮次记住计划的地方:本轮照常落盘,但不回填这里
768
+ planFile: '',
769
+ });
770
+ send({
771
+ type: 'notice',
772
+ text:
773
+ '这次用的是旧的无状态格式(只发了对白、没发 taskId):服务端无法在下一轮保留工具结果,' +
774
+ '模型可能会重复读取已经读过的文件。请改用「taskId + prompt」的新格式。',
775
+ });
776
+
777
+ try {
778
+ const outcome = await runAgent({ cfg, run, send, signal });
779
+ await finishOutcome({ outcome, run, send, res, cfg });
780
+ } catch (e) {
781
+ if (signal.aborted) return res.end();
782
+ if (cfg.verbose) console.error(`[task] 失败:${e?.message || e}`);
783
+ send({ type: 'error', message: e?.message || String(e), status: e?.status });
784
+ res.end();
785
+ }
786
+ }
787
+
788
+ async function handleTask(req, res, cfg) {
789
+ let body;
790
+ try {
791
+ body = await readBody(req, TASK_BODY_LIMIT);
792
+ } catch (e) {
793
+ return sendJson(res, e.status || 400, { error: e.message });
794
+ }
795
+
796
+ // 任务模式的工作目录是必需项 —— 没有它就退化成普通聊天了
797
+ let workDir;
798
+ try {
799
+ workDir = checkWorkDir(body.workDir);
800
+ } catch (e) {
801
+ return sendJson(res, e.status || 400, { error: e.message });
802
+ }
803
+
804
+ const mode = normalizeMode(body.mode);
805
+ const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : cfg.model;
806
+ const prompt = typeof body.prompt === 'string' ? body.prompt : '';
807
+
808
+ // 旧格式:没给 prompt、却给了有内容的全量对白
809
+ const legacy =
810
+ !prompt.trim() &&
811
+ cfg.compatLegacyTaskApi !== false &&
812
+ Array.isArray(body.messages) &&
813
+ body.messages.some((m) => m && typeof m.content === 'string' && m.content.trim());
814
+ if (legacy) return handleLegacyTask({ res, cfg, body, workDir, mode, model });
815
+
816
+ /* ---------- 新格式:服务端持有会话(方案 C) ---------- */
817
+
818
+ const taskId = body.taskId;
819
+ if (!isSafeId(taskId)) {
820
+ return sendJson(res, 400, { error: 'taskId 非法:任务模式需要它来定位服务端持有的会话(必须是 UUID)' });
821
+ }
822
+ if (!prompt.trim()) return sendJson(res, 400, { error: 'prompt 为空:这一轮要做什么?' });
823
+ if (runningTasks.has(taskId)) {
824
+ const info = runningTasks.get(taskId) || {};
825
+ const secs = Math.max(1, Math.round((Date.now() - (info.startedAt || Date.now())) / 1000));
826
+ return sendJson(res, 409, {
827
+ error: `这条任务正在执行中(已 ${secs} 秒)。同一任务不允许并发 —— 否则两个请求会同时改写同一份会话文件。请等它结束,或先点停止。`,
828
+ });
829
+ }
830
+
831
+ const system = buildTaskSystemPrompt(workDir, body.system, mode);
832
+ let loaded;
833
+ try {
834
+ loaded = loadOrCreateTaskSession({
835
+ sessions: cfg.taskSessions,
836
+ taskId,
837
+ workDir,
838
+ model,
839
+ mode,
840
+ system,
841
+ renderTask: () => cfg.store.get(taskId),
842
+ limits: sessionLimits(cfg),
843
+ });
844
+ } catch (e) {
845
+ return sendJson(res, e.status || 500, { error: `会话载入失败:${e?.message || e}` });
846
+ }
847
+ const session = loaded.session;
848
+ session.workingDir = workDir;
849
+ session.model = model;
850
+ session.mode = mode;
851
+ session.temperature = Number.isFinite(body.temperature) ? body.temperature : undefined;
852
+ session.maxTokens = Number.isFinite(body.maxTokens) && body.maxTokens > 0 ? body.maxTokens : undefined;
853
+ session.pending = null;
854
+ // 这条任务已经有落盘的计划(上一轮计划模式写的,或刷新/重启后从磁盘读回来的):
855
+ // 把「按这份计划执行」的约束重新拼进 system。换模式必须换 system 这件事
856
+ // 本来就由 refreshSessionContext 处理,这里只是把计划路径喂进去。
857
+ if (session.planFile) {
858
+ refreshSessionContext(session, {
859
+ workDir,
860
+ model,
861
+ mode,
862
+ system: buildTaskSystemPrompt(workDir, body.system, mode, { planFile: session.planFile }),
863
+ });
864
+ }
865
+ pushUser(session, prompt);
866
+
867
+ const { send, signal } = startTaskStream(res);
868
+ const storeExposed = storeInsideWorkDir(cfg.store.dir, workDir);
869
+ if (storeExposed) {
870
+ console.warn(`[task] 警告:存储目录 ${cfg.store.dir} 在工作目录 ${workDir} 之内,模型可以读到任务历史`);
871
+ }
872
+ // 开跑前统一准备:按预算压缩较早的工具正文,再把「已读文件清单」拼进 system。
873
+ // 必须在这里做、而不是每轮重建:压缩与清单一变,前缀就变,上游的 prompt 缓存全废。
874
+ const limits = sessionLimits(cfg);
875
+ const compaction = prepareTaskSession(session, {
876
+ limits,
877
+ cwd: workDir,
878
+ readFileDigest: limits.readFileDigest,
879
+ });
880
+ if (compaction.pruned && cfg.verbose) {
881
+ console.log(`[task] 历史预算:压缩 ${compaction.pruned} 条较早的工具结果(${compaction.before} → ${compaction.after} 字符)`);
882
+ }
883
+
884
+ send({
885
+ type: 'started',
886
+ runId: session.id,
887
+ taskId,
888
+ workDir,
889
+ mode,
890
+ modeLabel: MODE_LABELS[mode],
891
+ tools: toolsForMode(mode).map((t) => t.function.name),
892
+ storeExposed,
893
+ // 这条任务已落盘的计划文档(相对工作目录):页面据此显示「本次依据哪份计划执行」
894
+ planFile: session.planFile || '',
895
+ session: {
896
+ created: loaded.created,
897
+ migrated: session.migrated,
898
+ degradedTurns: session.degradedTurns,
899
+ messages: session.messages.length,
900
+ compacted: compaction.pruned,
901
+ },
902
+ });
903
+ if (loaded.created && session.migrated === 'partial') {
904
+ send({
905
+ type: 'notice',
906
+ text:
907
+ `已完成旧数据迁移:这条任务历史里有 ${session.degradedTurns} 轮工具记录来自旧版本,` +
908
+ `调用参数已不可恢复(结果正文保留了下来)。之后不会再出现「重复读同一个文件」了。`,
909
+ });
910
+ }
911
+
912
+ runningTasks.set(taskId, { runId: session.id, startedAt: Date.now() });
913
+ try {
914
+ const outcome = await runAgent({ cfg, run: session, send, signal });
915
+ await finishOutcome({ outcome, run: session, send, res, cfg });
916
+ } catch (e) {
917
+ if (signal.aborted) {
918
+ res.end();
919
+ return;
920
+ }
921
+ if (cfg.verbose) console.error(`[task] 失败:${e?.message || e}`);
922
+ send({ type: 'error', message: e?.message || String(e), status: e?.status });
923
+ res.end();
924
+ } finally {
925
+ // 无论正常结束、报错、挂起还是用户中断,会话都要留下来 ——
926
+ // 这正是原来丢掉的那一半:工具结果从来没有跨轮次活下来过。
927
+ persistTaskSession(cfg, session);
928
+ runningTasks.delete(taskId);
929
+ }
930
+ }
931
+
932
+ async function handleApprove(req, res, cfg) {
933
+ let body;
934
+ try {
935
+ body = await readBody(req);
936
+ } catch (e) {
937
+ return sendJson(res, e.status || 400, { error: e.message });
938
+ }
939
+
940
+ const run = takeRun(String(body.runId || ''));
941
+ if (!run) {
942
+ const mins = AGENT_LIMITS.APPROVAL_TTL_MS / 60000;
943
+ return sendJson(res, 410, { error: `该任务已过期或不存在(挂起态保留 ${mins} 分钟),请重新发起任务` });
944
+ }
945
+ if (!run.pending || run.pending.id !== body.callId) {
946
+ return sendJson(res, 409, { error: '待批准的工具调用不匹配,可能已被处理' });
947
+ }
948
+
949
+ const { send, signal } = startTaskStream(res);
950
+ // 批准后的续跑同样会写会话文件,所以要跟新起一轮一样占住互斥
951
+ const taskId = isSafeId(run.taskId) ? run.taskId : null;
952
+ if (taskId) {
953
+ if (runningTasks.has(taskId)) {
954
+ return sendJson(res, 409, { error: '这条任务正在执行中,请等它结束再处理这次批准。' });
955
+ }
956
+ runningTasks.set(taskId, { runId: run.id, startedAt: Date.now() });
957
+ }
958
+ try {
959
+ // 续跑前同样要过一遍预算与清单:挂起态可能已经落盘很久(默认 60 分钟),
960
+ // 期间用户完全可能改过配置或手改过那些文件
961
+ prepareTaskSession(run, {
962
+ limits: sessionLimits(cfg),
963
+ cwd: run.workingDir,
964
+ readFileDigest: sessionLimits(cfg).readFileDigest,
965
+ });
966
+ const outcome = await resumeWithApproval({ cfg, run, send, signal, approved: Boolean(body.approved) });
967
+ // 这一轮已经做完(或被中止)就把挂起态彻底丢掉:否则磁盘上那份还留着 pending,
968
+ // 页面刷新后会冒出一张「已经决定过」的批准卡片,点下去只会得到 409。
969
+ // 如果又挂起了,finishOutcome 里会重新 saveRun,所以这里不能无脑删。
970
+ if (outcome.status !== 'awaiting-approval') dropRun(run.id);
971
+ await finishOutcome({ outcome, run, send, res, cfg });
972
+ } catch (e) {
973
+ dropRun(run.id);
974
+ if (signal.aborted) {
975
+ res.end();
976
+ return;
977
+ }
978
+ if (cfg.verbose) console.error(`[task] 批准后续跑失败:${e?.message || e}`);
979
+ send({ type: 'error', message: e?.message || String(e), status: e?.status });
980
+ res.end();
981
+ } finally {
982
+ // 续跑用的 run 来自挂起态落盘(另一个对象),跑完必须把模型态会话写回任务会话
983
+ persistTaskSession(cfg, run);
984
+ if (taskId) runningTasks.delete(taskId);
985
+ }
986
+ }
987
+
988
+ /** 任务数据存到磁盘后,服务端就是唯一真相;这里把增删改查都收口 */
989
+ async function handleStore(req, res, cfg, p) {
990
+ const store = cfg.store;
991
+
992
+ if (p === '/api/store') {
993
+ if (req.method !== 'GET') return sendJson(res, 405, { error: `/api/store 只支持 GET,收到 ${req.method}` });
994
+ return sendJson(res, 200, {
995
+ ...store.stats(),
996
+ warnings: store.warnings,
997
+ source: cfg.storeSource,
998
+ // 模型态会话(方案 C)占用:用户要能看见它长到多大了
999
+ sessions: cfg.taskSessions.stats(),
1000
+ });
1001
+ }
1002
+
1003
+ if (p === '/api/tasks') {
1004
+ if (req.method === 'GET') {
1005
+ const removed = store.prune(); // 列表时顺手清一次,用户不必手动点清理
1006
+ dropTaskSessions(cfg, removed); // 任务没了,它的模型态会话也要一起走,否则是孤儿泄漏
1007
+ // running 是进程内的即时状态,不入库:侧边栏据此显示「进行中 / 已完成」
1008
+ const tasks = store.list().map((t) => ({ ...t, running: runningTasks.has(t.id) }));
1009
+ return sendJson(res, 200, { tasks, removed, ...store.stats() });
1010
+ }
1011
+ if (req.method === 'POST') {
1012
+ let body;
1013
+ try {
1014
+ body = await readBody(req, TASK_BODY_LIMIT);
1015
+ } catch (e) {
1016
+ return sendJson(res, e.status || 400, { error: e.message });
1017
+ }
1018
+ try {
1019
+ const task = store.create({
1020
+ id: body.id,
1021
+ title: body.title,
1022
+ workDir: body.workDir,
1023
+ mode: body.mode,
1024
+ messages: body.messages,
1025
+ // 任务树:带上 parentId 就是「在某条任务下派生」,服务端会校验父任务同目录且存在
1026
+ parentId: body.parentId,
1027
+ });
1028
+ return sendJson(res, 201, { task });
1029
+ } catch (e) {
1030
+ return sendJson(res, e.status || 400, { error: e.message });
1031
+ }
1032
+ }
1033
+ return sendJson(res, 405, { error: `/api/tasks 不支持 ${req.method}` });
1034
+ }
1035
+
1036
+ if (p === '/api/tasks/prune') {
1037
+ if (req.method !== 'POST') return sendJson(res, 405, { error: `/api/tasks/prune 只支持 POST,收到 ${req.method}` });
1038
+ const removed = store.prune();
1039
+ dropTaskSessions(cfg, removed);
1040
+ return sendJson(res, 200, { removed, ...store.stats() });
1041
+ }
1042
+
1043
+ // 剩下的都是 /api/tasks/<id>
1044
+ let id;
1045
+ try {
1046
+ id = decodeURIComponent(p.slice('/api/tasks/'.length));
1047
+ } catch {
1048
+ return sendJson(res, 400, { error: '任务 id 编码非法' });
1049
+ }
1050
+ if (!isSafeId(id)) return sendJson(res, 400, { error: '任务 id 非法' });
1051
+
1052
+ if (req.method === 'GET') {
1053
+ const task = store.get(id);
1054
+ if (!task) return sendJson(res, 404, { error: '任务不存在或已过期' });
1055
+ return sendJson(res, 200, { task });
1056
+ }
1057
+
1058
+ if (req.method === 'PUT' || req.method === 'PATCH') {
1059
+ let body;
1060
+ try {
1061
+ body = await readBody(req, TASK_BODY_LIMIT);
1062
+ } catch (e) {
1063
+ return sendJson(res, e.status || 400, { error: e.message });
1064
+ }
1065
+ const task = store.save(id, {
1066
+ title: body.title,
1067
+ workDir: body.workDir,
1068
+ mode: body.mode,
1069
+ messages: body.messages,
1070
+ renamed: body.renamed,
1071
+ });
1072
+ if (!task) return sendJson(res, 404, { error: '任务不存在或已过期' });
1073
+ return sendJson(res, 200, { task });
1074
+ }
1075
+
1076
+ if (req.method === 'DELETE') {
1077
+ const okDel = store.remove(id);
1078
+ if (!okDel) return sendJson(res, 404, { error: '任务不存在' });
1079
+ dropTaskSessions(cfg, [id]);
1080
+ return sendJson(res, 200, { ok: true, ...store.stats() });
1081
+ }
1082
+
1083
+ return sendJson(res, 405, { error: `/api/tasks/<id> 不支持 ${req.method}` });
1084
+ }
1085
+
1086
+ /* ---------- 路由 ---------- */
1087
+
1088
+ /**
1089
+ * 清掉「任务已经不在了」的会话文件:任务被删时我们会顺手删会话,但服务没跑的时候
1090
+ * 用户可能直接改过存储目录,或者上次删到一半崩了。启动时扫一遍最省心(最多 20 条任务)。
1091
+ */
1092
+ function sweepOrphanSessions(cfg) {
1093
+ let removed = 0;
1094
+ try {
1095
+ const live = new Set(cfg.store.list().map((t) => t.id));
1096
+ for (const rec of cfg.taskSessions.list()) {
1097
+ if (live.has(rec.taskId)) continue;
1098
+ if (cfg.taskSessions.drop(rec.taskId)) removed++;
1099
+ }
1100
+ } catch {
1101
+ /* 扫不动就算了,不阻塞启动 */
1102
+ }
1103
+ return removed;
1104
+ }
1105
+
1106
+ export function createHubServer(cfg) {
1107
+ // 允许被直接构造(测试、嵌入使用):没给配置就自己解析一次,缺的字段从默认值补齐。
1108
+ // runHub 会先解析好再传进来,这里只是兜底,不改变那条路径的行为。
1109
+ if (!cfg.settingsFile) cfg.settingsFile = settingsFilePath(envOf(cfg));
1110
+ if (!cfg.settings) applySettings(cfg, resolveSettings({ file: cfg.settingsFile, env: envOf(cfg), args: null }), { onlyMissing: true });
1111
+
1112
+ // 目录浏览与任务记录接口会把本机目录结构与文件正文暴露给调用方,
1113
+ // 只在仅本机监听(或显式放行)时开放
1114
+ const fsAllowed = () => cfg.fsEnabled;
1115
+
1116
+ return http.createServer(async (req, res) => {
1117
+ const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
1118
+ const p = url.pathname;
1119
+ if (cfg.verbose) console.log(`[web] ${req.method} ${p}`);
1120
+
1121
+ try {
1122
+ // 两个页面共用一个配置:聊天只读前几个字段,任务还要读 store / modes / limits
1123
+ if (p === '/api/config' && req.method === 'GET') {
1124
+ const allowed = fsAllowed();
1125
+ // 先按磁盘重算一次:手工编辑了 config.json / credentials.json 之后,刷新页面就该生效,
1126
+ // 而不是要重启服务(密钥那条尤其明显 —— 它现在也能由界面写)
1127
+ refreshSettings(cfg);
1128
+ // 先刷一次 MCP 清单再报工具:不刷的话,「页面刷新」这个动作在网关控制台里看不到任何
1129
+ // GET /v1/mcp/tools,页面上的工具列表也只有内置那几个(缓存要等某次任务开跑才被填)。
1130
+ // best-effort:网关没开 MCP / 断网 / 密钥无效时静默失败,行为与改动前一致;
1131
+ // 60 秒 TTL 内不会重复出网(频繁刷新页面不会变成洪水)。
1132
+ // 没密钥时直接跳过:拿空 Bearer 去撞网关只会白等一轮,页面本来就会显示「未配置」。
1133
+ if (cfg.key) await refreshMcpTools({ baseUrl: cfg.baseUrl, key: cfg.key });
1134
+ const info = {
1135
+ baseUrl: cfg.baseUrl,
1136
+ model: cfg.model,
1137
+ hasKey: Boolean(cfg.key),
1138
+ keyMask: maskKey(cfg.key),
1139
+ // 密钥从哪来:页面据此说「环境变量给的,改配置文件没用」还是「在设置里填」
1140
+ keySource: cfg.keySource || '',
1141
+ keySourceText: secretSourceText(cfg.keySource || ''),
1142
+ keyFile: cfg.secretFile || '',
1143
+ mode: 'hub',
1144
+ fsEnabled: allowed,
1145
+ modes: MODES.map((m) => ({ id: m, label: MODE_LABELS[m], tools: toolsForMode(m).map((t) => t.function.name) })),
1146
+ defaultMode: cfg.defaultMode,
1147
+ limits: { maxSteps: resolveMaxSteps(cfg.maxSteps), runs: runStats() },
1148
+ // 这里给的是「当前真正可用的工具」:bash 未由宿主开启时不会出现在列表里
1149
+ tools: availableTools().map((t) => ({ name: t.function.name, description: t.function.description })),
1150
+ };
1151
+ // 存储目录是本机路径,只在放行(仅本机监听或 --allow-remote-fs)时才下发;
1152
+ // 未放行时任务相关接口本来就全是 403,页面拿不到路径也不影响使用。
1153
+ if (allowed) info.store = { ...cfg.store.stats(), warnings: cfg.store.warnings, source: cfg.storeSource };
1154
+ return sendJson(res, 200, info);
1155
+ }
1156
+
1157
+ // `default` 是「配置里没写 model 时服务端会用哪个」——设置面板拿它给下拉的第一项起名,
1158
+ // 两个分支都回:拉不到列表时页面更要说清"默认是谁"。
1159
+ if (p === '/api/models' && req.method === 'GET') {
1160
+ // 没密钥就别去撞上游了:页面拿 needsKey 直接引导到设置面板,比回一句 401 明白
1161
+ if (!cfg.key) {
1162
+ return sendJson(res, 200, {
1163
+ models: [],
1164
+ default: cfg.model,
1165
+ needsKey: true,
1166
+ error: '还没有配置密钥:在「设置」里填 sk-xxx,或 gateway-agent config set key sk-xxx',
1167
+ });
1168
+ }
1169
+ try {
1170
+ return sendJson(res, 200, { models: await fetchModels(cfg), default: cfg.model });
1171
+ } catch (e) {
1172
+ return sendJson(res, e.status && e.status >= 400 ? 200 : 502, { models: [], default: cfg.model, error: e.message });
1173
+ }
1174
+ }
1175
+
1176
+ // ---------- 手册页(/manual)的数据源:三端指令对照 ----------
1177
+ // 清单**不手写**:CLI 行来自 lib/commands.js,任务页行来自 public/task-slash.js(同一份表),
1178
+ // 手册页拿到什么画什么 —— 谁少了一条,tests/manual.test.mjs 会当场抓住。
1179
+ if (p === '/api/commands' && req.method === 'GET') {
1180
+ return sendJson(res, 200, {
1181
+ surfaces: [
1182
+ {
1183
+ id: 'cli',
1184
+ label: '命令行 REPL(gateway-agent)',
1185
+ where: '在终端跑 gateway-agent;命令表见 lib/commands.js',
1186
+ commands: commandRows(),
1187
+ },
1188
+ {
1189
+ id: 'hub',
1190
+ label: '任务页(/task)',
1191
+ where: '本服务的 /task:模型真读写你选的目录',
1192
+ commands: taskSlash.commandRows(),
1193
+ },
1194
+ {
1195
+ id: 'web',
1196
+ label: '本机聊天页(/)',
1197
+ where: '本服务的 /:纯对话,不碰文件',
1198
+ commands: [],
1199
+ note: '这一端没有斜杠指令:聊天页只把消息发给模型。(网关自带的 Web 对话页是另一个仓库的产品,另有它自己的一套。)',
1200
+ },
1201
+ ],
1202
+ manual: '/manual',
1203
+ });
1204
+ }
1205
+
1206
+ // 费用粗估:单价表只有一份(lib/pricing.js),任务页 /cost 与 CLI /cost 因此同口径
1207
+ if (p === '/api/cost' && req.method === 'GET') {
1208
+ const usage = {
1209
+ prompt: Number(url.searchParams.get('prompt')) || 0,
1210
+ completion: Number(url.searchParams.get('completion')) || 0,
1211
+ };
1212
+ const model = url.searchParams.get('model') || cfg.model || '';
1213
+ return sendJson(res, 200, { ...estimateCost(usage, model), text: formatCost(usage, model) });
1214
+ }
1215
+
1216
+ // ---------- 聊天 ----------
1217
+ if (p === '/api/chat') {
1218
+ if (req.method !== 'POST') return sendJson(res, 405, { error: `/api/chat 只支持 POST,收到 ${req.method}` });
1219
+ if (requireKey(res, cfg)) return;
1220
+ return await handleChat(req, res, cfg);
1221
+ }
1222
+
1223
+ // ---------- 任务 ----------
1224
+ // 页面刷新后用它把「待批准」的卡片恢复出来(挂起态已落盘,重启服务也还在)
1225
+ if (p === '/api/task/pending' && req.method === 'GET') {
1226
+ if (!fsAllowed()) return sendJson(res, 403, { error: '待批准查询已禁用:服务未绑定在本机地址' });
1227
+ const taskId = url.searchParams.get('taskId');
1228
+ const all = listRuns();
1229
+ return sendJson(res, 200, {
1230
+ runs: taskId ? all.filter((r) => r.taskId === taskId) : all,
1231
+ ttlMinutes: AGENT_LIMITS.APPROVAL_TTL_MS / 60000,
1232
+ });
1233
+ }
1234
+
1235
+ if (p === '/api/fs/roots' && req.method === 'GET') {
1236
+ if (!fsAllowed()) return sendJson(res, 403, { error: '目录浏览已禁用:服务未绑定在本机地址' });
1237
+ return sendJson(res, 200, { roots: listRoots() });
1238
+ }
1239
+
1240
+ if (p === '/api/fs/list' && req.method === 'GET') {
1241
+ if (!fsAllowed()) return sendJson(res, 403, { error: '目录浏览已禁用:服务未绑定在本机地址' });
1242
+ const target = url.searchParams.get('path');
1243
+ if (!target) return sendJson(res, 400, { error: '缺少 path 参数' });
1244
+ try {
1245
+ return sendJson(res, 200, browseDir(target));
1246
+ } catch (e) {
1247
+ return sendJson(res, e.status || 400, { error: e.message });
1248
+ }
1249
+ }
1250
+
1251
+ // ---------- 项目记忆(任务页指令 /instructions、/init) ----------
1252
+ // 和目录浏览同一道闸门:这两个接口会回传本机路径与文件正文
1253
+ if (p === '/api/memory' && req.method === 'GET') {
1254
+ if (!fsAllowed()) return sendJson(res, 403, { error: '项目记忆已禁用:服务未绑定在本机地址' });
1255
+ try {
1256
+ return sendJson(res, 200, readMemory(checkWorkDir(url.searchParams.get('path'))));
1257
+ } catch (e) {
1258
+ return sendJson(res, e.status || 400, { error: e.message });
1259
+ }
1260
+ }
1261
+
1262
+ // 服务端自己决定内容的写入:**不经过审批闸门**(内容里没有模型给的东西),
1263
+ // 所以它只做两件事:工作目录沙箱校验 + 已存在就不覆盖(除非显式 force)。
1264
+ if (p === '/api/init' && req.method === 'POST') {
1265
+ if (!fsAllowed()) return sendJson(res, 403, { error: '初始化已禁用:服务未绑定在本机地址' });
1266
+ let body;
1267
+ try {
1268
+ body = await readBody(req);
1269
+ } catch (e) {
1270
+ return sendJson(res, 400, { error: `请求体不是合法 JSON:${e.message}` });
1271
+ }
1272
+ try {
1273
+ const workDir = checkWorkDir(body.workDir);
1274
+ const r = initInstructions({ workDir, force: Boolean(body.force) });
1275
+ return sendJson(res, 200, { ...r, memory: readMemory(workDir) });
1276
+ } catch (e) {
1277
+ return sendJson(res, e.status || 400, { error: e.message });
1278
+ }
1279
+ }
1280
+
1281
+ // 任务记录里含工具读过的文件正文,属于本机数据:和目录浏览一样只对本机开放
1282
+ if (p === '/api/store' || p === '/api/tasks' || p.startsWith('/api/tasks/')) {
1283
+ if (!fsAllowed()) {
1284
+ return sendJson(res, 403, { error: '任务记录存储已禁用:服务未绑定在本机地址(可用 --allow-remote-fs 放行)' });
1285
+ }
1286
+ return await handleStore(req, res, cfg, p);
1287
+ }
1288
+
1289
+ // 配置里含本机路径(lastWorkDir / store.dir),与任务记录同级看待
1290
+ if (p === '/api/settings') {
1291
+ if (!fsAllowed()) {
1292
+ return sendJson(res, 403, { error: '配置读写已禁用:服务未绑定在本机地址(可用 --allow-remote-fs 放行)' });
1293
+ }
1294
+ return await handleSettings(req, res, cfg);
1295
+ }
1296
+
1297
+ if (p === '/api/task') {
1298
+ if (req.method !== 'POST') return sendJson(res, 405, { error: `/api/task 只支持 POST,收到 ${req.method}` });
1299
+ if (requireKey(res, cfg)) return;
1300
+ return await handleTask(req, res, cfg);
1301
+ }
1302
+
1303
+ if (p === '/api/task/approve') {
1304
+ if (req.method !== 'POST') return sendJson(res, 405, { error: `/api/task/approve 只支持 POST,收到 ${req.method}` });
1305
+ if (requireKey(res, cfg)) return;
1306
+ return await handleApprove(req, res, cfg);
1307
+ }
1308
+
1309
+ if (p === '/api/task/cancel' && req.method === 'POST') {
1310
+ const body = await readBody(req).catch(() => ({}));
1311
+ dropRun(String(body.runId || ''));
1312
+ return sendJson(res, 200, { ok: true });
1313
+ }
1314
+
1315
+ if (p.startsWith('/api/')) return sendJson(res, 404, { error: `未知接口 ${p}` });
1316
+
1317
+ if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, p);
1318
+ return sendJson(res, 405, { error: `不支持的方法 ${req.method}` });
1319
+ } catch (e) {
1320
+ if (!res.headersSent) sendJson(res, 500, { error: e?.message || String(e) });
1321
+ else res.end();
1322
+ }
1323
+ });
1324
+ }
1325
+
1326
+ /* ---------- 主流程 ---------- */
1327
+
1328
+ export function runHub(argv, { entry = 'web' } = {}) {
1329
+ const args = parseArgs(argv, VALUE_FLAGS, BOOL_FLAGS);
1330
+ if (args.help) {
1331
+ console.log(HELP);
1332
+ return null;
1333
+ }
1334
+
1335
+ // 密钥:--key > 环境变量 > .env > credentials.json(见 lib/secrets.js)。
1336
+ // 环境里给了密钥时,文件不参与 —— 优先级与改动前完全一致。
1337
+ const secretFile = secretFilePath();
1338
+ const secret = resolveSecretKey({ args, env: process.env, file: secretFile });
1339
+ const key = secret.key;
1340
+ const keyFromEnv = Boolean(resolveKey(args));
1341
+ for (const w of [secret.warning].filter(Boolean)) console.warn(`[警告] ${w}`);
1342
+ // 配置的唯一解析口径(lib/settings.js):--flag > 环境变量 > config.json > 内置默认
1343
+ const settingsFile = settingsFilePath();
1344
+ const settings = resolveSettings({ file: settingsFile, env: process.env, args });
1345
+ for (const w of settings.warnings) console.warn(`[警告] ${w}`);
1346
+ const baseUrl = resolveBaseUrl(args);
1347
+ const model = strArg(args.model) || DEFAULT_MODEL;
1348
+ // 老的两个服务各用各的环境变量名,这里都认
1349
+ const port =
1350
+ resolvePort(args, process.env, 'WEB_PORT', null) ??
1351
+ resolvePort({}, process.env, 'TASK_WEB_PORT', DEFAULT_PORT);
1352
+ const host = strArg(args.host) || strArg(process.env.WEB_HOST) || strArg(process.env.TASK_WEB_HOST) || '127.0.0.1';
1353
+
1354
+ // 没有密钥**不再退出**:以前这里 exit(1),用户连界面都进不去,只能去手写 .env。
1355
+ // 现在照常起服务,页面能打开、设置面板能填密钥 —— 缺密钥的请求由各接口回 409 needsKey。
1356
+ if (!key) {
1357
+ console.warn('[警告] 还没有配置密钥:页面能打开,但聊天与任务会提示缺少密钥。');
1358
+ console.warn(' 三种给法(任选其一):');
1359
+ console.warn(' ① 在页面「设置」里填(存到 credentials.json,仅本用户可读)');
1360
+ console.warn(' ② gateway-agent config set key sk-xxx');
1361
+ console.warn(' ③ 环境变量 GATEWAY_KEY / SK,或启动参数 --key sk-xxx');
1362
+ }
1363
+ if (port === null) {
1364
+ console.error(`[错误] 端口非法:${strArg(args.port) || process.env.WEB_PORT || process.env.TASK_WEB_PORT}`);
1365
+ process.exit(1);
1366
+ }
1367
+
1368
+ const loopback = isLoopbackHost(host);
1369
+ const fsEnabled = loopback || Boolean(args['allow-remote-fs']);
1370
+ if (!loopback && !fsEnabled) {
1371
+ console.warn(`[警告] 绑定在 ${host} 且未加 --allow-remote-fs:目录浏览与任务记录接口将返回 403。`);
1372
+ } else if (!loopback) {
1373
+ console.warn('[警告] 已用 --allow-remote-fs 开放目录浏览与任务记录,任何能访问该端口的人都能浏览本机目录、读取任务历史。');
1374
+ }
1375
+
1376
+ // bash 与 --allow-remote-fs 是同一性质:显式、危险、默认关。
1377
+ // 必须在建服务之前打开 —— /api/config 与每轮请求的工具清单都由 availableTools() 现算,
1378
+ // 关着的时候 bash 压根不在清单里(模型连试都不会试)。这正是「任务模式跑不了测试、
1379
+ // 只能反复说已静态复核」的根因:不是权限被拒,是这支笔从没发给过它。
1380
+ setBashEnabled(Boolean(args['allow-bash']));
1381
+ if (isBashEnabled()) {
1382
+ console.warn(
1383
+ '[警告] 已用 --allow-bash 开启命令执行:任务页「自动」模式下模型跑命令不会再逐个问你'
1384
+ + '(手动模式仍会挂起等你批准)。命令仍在工作目录内执行,风险请自行评估。',
1385
+ );
1386
+ }
1387
+
1388
+ // 任务数据落盘。即使只用聊天页也要初始化 —— 任务页随时能打开
1389
+ let resolved;
1390
+ try {
1391
+ // args/env 已经被 settings 合并过了(flag > env > config.json),这里再传一次也无妨
1392
+ resolved = resolveStoreDir(strArg(settings.values['store.dir']) || args.store || null, ROOT_DIR);
1393
+ } catch (e) {
1394
+ console.error(`[错误] ${e.message}`);
1395
+ process.exit(1);
1396
+ }
1397
+ let store;
1398
+ try {
1399
+ store = new TaskStore(resolved.dir).init();
1400
+ } catch (e) {
1401
+ console.error(`[错误] 任务存储初始化失败:${e.message}`);
1402
+ process.exit(1);
1403
+ }
1404
+ if (resolved.notice) console.log(`[提示] ${resolved.notice}`);
1405
+ if (resolved.warning) console.warn(`[警告] ${resolved.warning}`);
1406
+ for (const w of store.warnings) console.warn(`[警告] ${w}`);
1407
+
1408
+ const mode = normalizeMode(args.mode || process.env.TASK_MODE || 'manual');
1409
+ // 挂起态落盘:刷新页面、甚至重启服务后,待批准的写入都还在
1410
+ setRunPersistence(createRunStore(path.join(store.dir, 'pending')));
1411
+
1412
+ const cfg = {
1413
+ baseUrl,
1414
+ model,
1415
+ key,
1416
+ verbose: Boolean(args.verbose),
1417
+ fsEnabled,
1418
+ store,
1419
+ storeSource: resolved.source,
1420
+ defaultMode: mode,
1421
+ // 单次任务的模型轮次上限:--max-steps / TASK_MAX_STEPS 可覆盖
1422
+ maxSteps: resolveMaxSteps(args['max-steps'] ?? process.env.TASK_MAX_STEPS),
1423
+ // 旧的无状态 /api/task 是否继续接受(兼容期默认开,见 docs/20260916 的 7.1)
1424
+ compatLegacyTaskApi: true,
1425
+ taskTuning: {},
1426
+ settingsFile,
1427
+ env: process.env,
1428
+ // 密钥文件与「密钥是不是环境给的」:refreshSettings() 靠这两个字段决定要不要重读文件
1429
+ secretFile,
1430
+ keyFromEnv,
1431
+ keySource: secret.source,
1432
+ // 运行态归服务端所有:配置文件改了要能立刻套上来
1433
+ settingsAuthoritative: true,
1434
+ settingsArgs: args,
1435
+ };
1436
+ // 把 config.json 里的值套上去(含 model / mode / maxSteps / 会话与历史预算 / 兼容开关)
1437
+ applySettings(cfg, settings);
1438
+ // 模型态会话落盘(方案 C)。限制传函数而不是对象:配置改了立刻生效,不必重建 store
1439
+ // 会话(模型态上下文)默认跟着任务目录走;想单独放一份(比如任务目录在被同步的盘上、
1440
+ // 会话想留在本机快盘)可以用 store.sessionDir 指到别处
1441
+ const sessionDirSetting = strArg(settings.values['store.sessionDir']);
1442
+ cfg.taskSessions = createTaskSessionStore(sessionDirSetting ? path.resolve(ROOT_DIR, sessionDirSetting) : path.join(store.dir, 'sessions'), () =>
1443
+ sessionLimits(cfg),
1444
+ );
1445
+ const orphans = sweepOrphanSessions(cfg);
1446
+ const server = createHubServer(cfg);
1447
+
1448
+ server.on('error', (e) => {
1449
+ if (e.code === 'EADDRINUSE') console.error(`[错误] 端口 ${port} 已被占用:换一个 --port,或先关掉占用进程`);
1450
+ else console.error(`[错误] 服务启动失败:${e.message}`);
1451
+ process.exit(1);
1452
+ });
1453
+
1454
+ server.listen(port, host, () => {
1455
+ const s = store.stats();
1456
+ console.log('LLM API Gateway · 本地 Web UI');
1457
+ console.log(` 聊天 http://${host}:${port}/`);
1458
+ console.log(` 任务 http://${host}:${port}/task`);
1459
+ console.log(` 网关 ${baseUrl}`);
1460
+ console.log(` 模型 ${model}`);
1461
+ // 密钥一行要能回答「配没配、从哪来」——来源是环境时,credentials.json 不参与,这一点要说清
1462
+ console.log(` 密钥 ${key ? `${maskKey(key)}(${secretSourceText(secret.source)})` : '未配置'}`);
1463
+ if (!key) {
1464
+ console.log(' 填法:页面「设置」里填(存 credentials.json,仅本用户可读)');
1465
+ console.log(' 或 gateway-agent config set key sk-xxx;或环境变量 GATEWAY_KEY / --key');
1466
+ }
1467
+ console.log(` 工具 ${availableTools().map((t) => t.function.name).join(' / ')}(计划模式只给只读工具)`);
1468
+ console.log(` 模式 默认 ${MODE_LABELS[mode]}(${mode})· 任务页可随时切换`);
1469
+ console.log(` 存储 ${s.dir}`);
1470
+ console.log(` (${STORE_SOURCE_TEXT[resolved.source] || resolved.source},当前 ${s.taskCount}/${s.maxTasks} 条,滑动保留 ${s.ttlDays} 天)`);
1471
+ const sess = cfg.taskSessions.stats();
1472
+ console.log(` 会话 ${sess.count} 条 / ${(sess.bytes / 1024).toFixed(0)}KB(模型态消息,让模型不必重复读文件)`);
1473
+ if (orphans) console.log(` 启动时清掉了 ${orphans} 个没有任务的孤儿会话`);
1474
+ console.log(` 配置 ${settingsFile}${settings.exists ? '' : '(还没有,用默认值;gateway-agent config set 可改)'}`);
1475
+ console.log(` 密钥文件 ${secretFile}${secret.source === 'file' ? '' : '(当前没用它:密钥来自环境变量或启动参数)'}`);
1476
+ console.log(' 停止 Ctrl+C');
1477
+ console.log('');
1478
+ console.log(` 轮次 单个任务最多 ${cfg.maxSteps} 轮模型调用(--max-steps 可调);撞到上限会先收尾给结论,再给「继续执行」`);
1479
+ console.log(' 写入按当前模式处理:手动逐个批准 / 自动直接执行 / 计划只读先出方案。');
1480
+ if (entry === 'task') {
1481
+ console.log(' 提示 任务模式与聊天已合并到同一端口,/task 是任务页、/ 是聊天页。');
1482
+ }
1483
+ });
1484
+
1485
+ for (const sig of ['SIGINT', 'SIGTERM']) {
1486
+ process.on(sig, () => {
1487
+ console.log('\n[web] 正在关闭…');
1488
+ server.close(() => process.exit(0));
1489
+ setTimeout(() => process.exit(0), 1500).unref();
1490
+ });
1491
+ }
1492
+
1493
+ return server;
1494
+ }