agent-loft 0.0.0-stage → 0.2.7

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
@@ -0,0 +1,645 @@
1
+ // Web 的 REST 接口。实时的东西(事件流、发消息、点授权)走 WebSocket,见 server.ts。
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { AGENT_IDS, config, listWorkspaceDirs, resolveWorkspacePath, saveConfig, type AppConfig } from "../../core/config.js";
6
+ import { hub } from "../../core/hub.js";
7
+ import { interactions } from "../../core/interactions.js";
8
+ import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
9
+ import { getConversation, updateConversation } from "../../core/store.js";
10
+ import { saveBuffer } from "../../core/uploads.js";
11
+ import type { SavedAttachment } from "../../core/types.js";
12
+ import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
13
+ import { feishuBots } from "../feishu/index.js";
14
+ import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
15
+ import { FeishuClient } from "../feishu/client.js";
16
+ import {
17
+ checkSetupToken,
18
+ clearAttempts,
19
+ createSession,
20
+ destroySession,
21
+ hasPassword,
22
+ listSessions as listWebSessions,
23
+ takeAttempt,
24
+ revokeSession,
25
+ setPassword,
26
+ validateSession,
27
+ verifyPassword,
28
+ } from "./auth.js";
29
+ import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, sendJson } from "./http.js";
30
+ import { addSubscription, listSubscriptions, pushToAll, removeSubscription, vapidPublicKey } from "./push.js";
31
+
32
+ export const COOKIE = "aim_session";
33
+ const startedAt = Date.now();
34
+
35
+ // ---------- 上传 ----------
36
+
37
+ /** 上传后先存着,发消息时用 id 认领。一小时没人认领就作废 */
38
+ const uploads = new Map<string, { key: string; attachment: SavedAttachment; at: number }>();
39
+
40
+ /**
41
+ * 认领上传的附件。有任何一个已失效(id 不认识、不属于这条会话、文件已被删 —— 比如
42
+ * 另一个浏览器在这期间开了新会话、清掉了上传目录)就整条拒绝,让前端提示重新上传,
43
+ * 而不是悄悄少发一个附件。
44
+ */
45
+ export function takeUploads(key: string, ids: unknown): SavedAttachment[] {
46
+ if (!Array.isArray(ids)) return [];
47
+ const entries = ids.map((id) => (typeof id === "string" ? uploads.get(id) : undefined));
48
+ const bad = entries.filter((e) => !e || e.key !== key || !fs.existsSync(e.attachment.path)).length;
49
+ if (bad > 0) throw new HttpError(410, `有 ${bad} 个附件已失效(可能会话被重置过),请重新上传`);
50
+ for (const id of ids) uploads.delete(id as string);
51
+ return entries.map((e) => e!.attachment);
52
+ }
53
+
54
+ setInterval(() => {
55
+ const now = Date.now();
56
+ for (const [id, entry] of uploads) if (now - entry.at > 60 * 60 * 1000) uploads.delete(id);
57
+ }, 10 * 60 * 1000).unref();
58
+
59
+ // ---------- 飞书扫码创建应用 ----------
60
+
61
+ type Registration = {
62
+ status: "pending" | "success" | "access_denied" | "expired" | "timeout" | "error";
63
+ qrUrl: string;
64
+ qrSvg: string;
65
+ message?: string;
66
+ botId?: string;
67
+ };
68
+ const registrations = new Map<string, Registration>();
69
+
70
+ async function startRegistration(name: string, agents: string[]): Promise<{ id: string } & Registration> {
71
+ await initRegistration("feishu");
72
+ const begin = await beginRegistration("feishu");
73
+ const id = crypto.randomBytes(8).toString("hex");
74
+ const reg: Registration = { status: "pending", qrUrl: begin.qrUrl, qrSvg: await qrSvg(begin.qrUrl) };
75
+ registrations.set(id, reg);
76
+ void pollRegistration({ deviceCode: begin.deviceCode, interval: begin.interval, expireIn: begin.expireIn })
77
+ .then((outcome) => {
78
+ if (outcome.status !== "success") {
79
+ reg.status = outcome.status;
80
+ if (outcome.status === "error") reg.message = outcome.message;
81
+ return;
82
+ }
83
+ const cfg = structuredClone(config());
84
+ const botId = `bot_${crypto.randomBytes(3).toString("hex")}`;
85
+ const list = agents.filter((a) => (AGENT_IDS as readonly string[]).includes(a));
86
+ cfg.feishu.bots.push({
87
+ id: botId,
88
+ name: name || "飞书机器人",
89
+ enabled: true,
90
+ appId: outcome.result.appId,
91
+ appSecret: outcome.result.appSecret,
92
+ domain: outcome.result.domain,
93
+ allowFrom: outcome.result.openId ? [outcome.result.openId.toLowerCase()] : [],
94
+ agents: list.length ? list : ["claude"],
95
+ defaultAgent: list[0] ?? "claude",
96
+ });
97
+ saveConfig(cfg);
98
+ reg.status = "success";
99
+ reg.botId = botId;
100
+ })
101
+ .catch((err) => {
102
+ reg.status = "error";
103
+ reg.message = err instanceof Error ? err.message : String(err);
104
+ });
105
+ setTimeout(() => registrations.delete(id), 30 * 60 * 1000).unref();
106
+ return { id, ...reg };
107
+ }
108
+
109
+ // ---------- 配置读写 ----------
110
+
111
+ const MASK = "••••••";
112
+
113
+ function maskSecret(secret: string): string {
114
+ return secret ? `${MASK}${secret.slice(-4)}` : "";
115
+ }
116
+
117
+ function publicConfig(): unknown {
118
+ const cfg = structuredClone(config()) as AppConfig & { web: { passwordHash?: string } };
119
+ return {
120
+ ...cfg,
121
+ web: { ...cfg.web, passwordHash: undefined, hasPassword: !!config().web.passwordHash },
122
+ feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
123
+ };
124
+ }
125
+
126
+ function applyConfig(input: Partial<AppConfig>): void {
127
+ const current = config();
128
+ const next = structuredClone(current);
129
+ if (typeof input.workspaceRoot === "string" && input.workspaceRoot.trim()) next.workspaceRoot = input.workspaceRoot.trim();
130
+ if (typeof input.approvalTimeoutMs === "number") next.approvalTimeoutMs = Math.max(60_000, input.approvalTimeoutMs);
131
+ if (typeof input.sessionTtlMs === "number") next.sessionTtlMs = Math.max(0, input.sessionTtlMs);
132
+ if (input.agents) {
133
+ // 按 agent 逐个合并:只改了其中几项(比如没带 models)时,其余的保持原样
134
+ for (const id of AGENT_IDS) {
135
+ const patch = structuredClone(input.agents[id]) as Partial<AppConfig["agents"][typeof id]> | undefined;
136
+ if (!patch) continue;
137
+ if (patch.models !== undefined) {
138
+ patch.models = Array.isArray(patch.models) ? [...new Set(patch.models.filter((m) => typeof m === "string" && m))] : [];
139
+ }
140
+ Object.assign(next.agents[id], patch);
141
+ }
142
+ }
143
+ if (input.web) {
144
+ // 密码只能走专门的接口改
145
+ const { passwordHash: _ignored, ...web } = input.web as AppConfig["web"];
146
+ next.web = { ...next.web, ...web, passwordHash: current.web.passwordHash };
147
+ const q = Number(next.web.quotaRefreshMin);
148
+ next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
149
+ }
150
+ if (input.commands) {
151
+ // 只收字符串数组,别的一律当没传
152
+ const names = (v: unknown) =>
153
+ Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === "string" && s.length > 0 && s.length < 128))] : null;
154
+ for (const id of AGENT_IDS) {
155
+ const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown } | undefined>)[id];
156
+ if (!prefs) continue;
157
+ next.commands[id] = {
158
+ order: names(prefs.order) ?? next.commands[id].order,
159
+ hidden: names(prefs.hidden) ?? next.commands[id].hidden,
160
+ };
161
+ }
162
+ }
163
+ if (input.feishu) {
164
+ if (typeof input.feishu.watchdogMs === "number") next.feishu.watchdogMs = input.feishu.watchdogMs;
165
+ if (Array.isArray(input.feishu.bots)) {
166
+ next.feishu.bots = input.feishu.bots.map((b) => {
167
+ const old = current.feishu.bots.find((o) => o.id === b.id);
168
+ // 前端拿到的是打码后的密钥,原样传回来表示「没改」
169
+ const secret = !b.appSecret || b.appSecret.startsWith(MASK) ? (old?.appSecret ?? "") : b.appSecret;
170
+ return { ...b, appSecret: secret };
171
+ });
172
+ }
173
+ }
174
+ saveConfig(next);
175
+ }
176
+
177
+ // ---------- 鉴权 ----------
178
+
179
+ function setCookie(req: IncomingMessage, res: ServerResponse, token: string, maxAgeSec: number): void {
180
+ const parts = [
181
+ `${COOKIE}=${encodeURIComponent(token)}`,
182
+ "Path=/",
183
+ "HttpOnly",
184
+ "SameSite=Lax",
185
+ `Max-Age=${maxAgeSec}`,
186
+ ...(isHttps(req) ? ["Secure"] : []),
187
+ ];
188
+ res.setHeader("Set-Cookie", parts.join("; "));
189
+ }
190
+
191
+ export function authed(req: IncomingMessage): boolean {
192
+ return !!validateSession(cookies(req)[COOKIE]);
193
+ }
194
+
195
+ // ---------- 路由 ----------
196
+
197
+ function conversationOr404(key: string) {
198
+ const conv = getConversation(key);
199
+ if (!conv) throw new HttpError(404, "会话不存在");
200
+ return conv;
201
+ }
202
+
203
+ /** 这条会话能用的 agent:飞书会话受机器人配置约束,Web 会话全部已启用的都行 */
204
+ export function allowedAgentsFor(key: string): string[] {
205
+ const conv = getConversation(key);
206
+ const enabled = hub.enabledAgents().map((d) => d.id);
207
+ if (conv?.channel === "feishu") {
208
+ const bot = config().feishu.bots.find((b) => b.id === conv.channelId);
209
+ return bot ? bot.agents.filter((a) => enabled.includes(a)) : enabled;
210
+ }
211
+ return enabled;
212
+ }
213
+
214
+ export async function handleApi(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
215
+ const method = req.method ?? "GET";
216
+ const p = url.pathname;
217
+ const ip = clientIp(req);
218
+
219
+ // ----- 无需登录 -----
220
+ if (p === "/api/me" && method === "GET") {
221
+ sendJson(res, 200, { authed: authed(req), needsSetup: !hasPassword() });
222
+ return;
223
+ }
224
+ if (p === "/api/setup" && method === "POST") {
225
+ if (hasPassword()) throw new HttpError(400, "已经设置过密码了");
226
+ const body = await readJsonBody<{ token?: string; password?: string }>(req);
227
+ const left = takeAttempt(ip);
228
+ if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
229
+ if (!checkSetupToken(String(body.token ?? ""))) {
230
+ throw new HttpError(403, "设置码不对。它打印在 agent-loft 的启动日志里");
231
+ }
232
+ setPassword(String(body.password ?? ""));
233
+ clearAttempts(ip);
234
+ setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
235
+ sendJson(res, 200, { ok: true });
236
+ return;
237
+ }
238
+ if (p === "/api/login" && method === "POST") {
239
+ const body = await readJsonBody<{ password?: string }>(req);
240
+ const left = takeAttempt(ip);
241
+ if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
242
+ if (!hasPassword() || !verifyPassword(String(body.password ?? ""), config().web.passwordHash)) {
243
+ throw new HttpError(403, "密码不对");
244
+ }
245
+ clearAttempts(ip);
246
+ setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
247
+ sendJson(res, 200, { ok: true });
248
+ return;
249
+ }
250
+ if (p === "/api/logout" && method === "POST") {
251
+ destroySession(cookies(req)[COOKIE]);
252
+ setCookie(req, res, "", 0);
253
+ sendJson(res, 200, { ok: true });
254
+ return;
255
+ }
256
+
257
+ if (!authed(req)) throw new HttpError(401, "请先登录");
258
+
259
+ // ----- 总览 -----
260
+ if (p === "/api/bootstrap" && method === "GET") {
261
+ sendJson(res, 200, {
262
+ agents: [...AGENT_IDS].map((id) => {
263
+ const d = hub.driver(id);
264
+ return {
265
+ id,
266
+ label: d?.label ?? id,
267
+ enabled: hub.enabledAgents().some((e) => e.id === id),
268
+ capabilities: d?.capabilities ?? {},
269
+ // 输入框模型列表里显示哪些,空 = 全部
270
+ models: config().agents[id].models.filter((m) => typeof m === "string"),
271
+ };
272
+ }),
273
+ bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
274
+ workspaceRoot: config().workspaceRoot,
275
+ quotaRefreshMin: config().web.quotaRefreshMin,
276
+ conversations: hub.list(),
277
+ pending: interactions.listPending(),
278
+ serverTime: Date.now(),
279
+ });
280
+ return;
281
+ }
282
+
283
+ // ----- 系统通知(Web Push) -----
284
+ if (p === "/api/push" && method === "GET") {
285
+ sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions() });
286
+ return;
287
+ }
288
+ if (p === "/api/push/subscribe" && method === "POST") {
289
+ const body = await readJsonBody<{ subscription?: unknown }>(req);
290
+ try {
291
+ const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""));
292
+ sendJson(res, 200, { ok: true, label: record.label });
293
+ } catch (err) {
294
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
295
+ }
296
+ return;
297
+ }
298
+ if (p === "/api/push/unsubscribe" && method === "POST") {
299
+ const body = await readJsonBody<{ endpoint?: string }>(req);
300
+ sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? "")) });
301
+ return;
302
+ }
303
+ if (p === "/api/push/test" && method === "POST") {
304
+ const sent = await pushToAll({ title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" });
305
+ if (sent === 0) throw new HttpError(502, "没有推送成功:还没有设备开启通知,或推送服务拒绝了(看服务日志)");
306
+ sendJson(res, 200, { sent });
307
+ return;
308
+ }
309
+
310
+ // ----- 目录选择器:列工作区内的子目录 -----
311
+ if (p === "/api/dirs" && method === "GET") {
312
+ const listing = listWorkspaceDirs(url.searchParams.get("path") ?? "");
313
+ if (!listing) throw new HttpError(404, `目录不存在,或不在 ${config().workspaceRoot} 之内`);
314
+ sendJson(res, 200, listing);
315
+ return;
316
+ }
317
+
318
+ // ----- 会话 -----
319
+ if (p === "/api/conversations" && method === "GET") {
320
+ sendJson(res, 200, hub.list());
321
+ return;
322
+ }
323
+ if (p === "/api/conversations" && method === "POST") {
324
+ const body = await readJsonBody<{ agent?: string; cwd?: string }>(req);
325
+ const enabled = hub.enabledAgents().map((d) => d.id);
326
+ const agent = body.agent && enabled.includes(body.agent) ? body.agent : enabled[0];
327
+ if (!agent) throw new HttpError(400, "没有可用的 agent");
328
+ // 新对话发第一条消息前在页面上选的工作目录;和 /cd 一样只能在工作区之内
329
+ let cwd: string | null = null;
330
+ if (typeof body.cwd === "string" && body.cwd.trim()) {
331
+ cwd = resolveWorkspacePath(body.cwd.trim());
332
+ if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
333
+ }
334
+ const key = `web:${crypto.randomBytes(6).toString("hex")}`;
335
+ hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent });
336
+ if (cwd && cwd !== config().workspaceRoot) updateConversation(key, { cwd, cwdExplicit: true });
337
+ sendJson(res, 200, hub.info(key));
338
+ return;
339
+ }
340
+
341
+ const convMatch = /^\/api\/conversations\/([^/]+)(?:\/([a-z-]+))?$/.exec(p);
342
+ if (convMatch) {
343
+ const key = decodeURIComponent(convMatch[1]!);
344
+ const sub = convMatch[2];
345
+ const conv = conversationOr404(key);
346
+
347
+ if (!sub && method === "PATCH") {
348
+ const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
349
+ updateConversation(key, {
350
+ ...(typeof body.title === "string" ? { title: body.title.trim().slice(0, 80) } : {}),
351
+ ...(typeof body.hidden === "boolean" ? { hidden: body.hidden } : {}),
352
+ });
353
+ hub.emitEvent(key, { type: "meta" });
354
+ sendJson(res, 200, hub.info(key));
355
+ return;
356
+ }
357
+ if (!sub && method === "DELETE") {
358
+ // 只是从列表里藏起来:agent 侧的会话记录还在,随时能从「历史会话」恢复
359
+ await hub.drop(key, "会话已删除");
360
+ updateConversation(key, { hidden: true });
361
+ hub.emitEvent(key, { type: "meta" });
362
+ sendJson(res, 200, { ok: true });
363
+ return;
364
+ }
365
+ if (sub === "history" && method === "GET") {
366
+ // 读历史是异步的。要保证「历史快照 + 进行中这一轮的事件 + 之后的补发」首尾相接、
367
+ // 不重不漏,只在两种情况下接受这次读到的快照:
368
+ // a) 读的过程中这条会话没有任何新事件;
369
+ // b) 读之前和读之后是同一轮还在进行(快照里缺的部分由 live 事件补上)。
370
+ // 其余情况(读的过程中有一轮结束了、或者开始了新的一轮)都重读。
371
+ const turnStartSeq = () => hub.liveTurn(key)[0]?.seq ?? 0;
372
+ let messages: Awaited<ReturnType<typeof hub.history>> = [];
373
+ let consistent = false;
374
+ for (let i = 0; i < 5 && !consistent; i++) {
375
+ if (i > 0) await new Promise((r) => setTimeout(r, 200));
376
+ const before = hub.currentSeq();
377
+ const startBefore = turnStartSeq();
378
+ messages = await hub.history(key);
379
+ const changed = (hub.eventsSince(key, before) ?? [null]).length > 0;
380
+ const startAfter = turnStartSeq();
381
+ consistent = !changed || (startBefore !== 0 && startBefore === startAfter);
382
+ }
383
+ // 拿不到一致的快照:宁可让客户端稍后重试,也不能返回一份缺了回复、游标却已越过它的快照
384
+ if (!consistent) throw new HttpError(503, "这条会话正在快速变化,稍后重试");
385
+ // 以下同步执行:live 和 seq 是同一时刻的,客户端从 seq 往后订阅正好接上
386
+ const live = hub.liveTurn(key);
387
+ const seq = hub.currentSeq();
388
+ // 进行中的这一轮,agent 的记录里可能已经写进去了一部分,和 live 重复:
389
+ // 记录里最后一条用户消息就是这一轮的那条时,从它起砍掉,交给 live 来画
390
+ const liveUser = live.find((e) => e.ev.type === "user")?.ev as { text?: string } | undefined;
391
+ if (liveUser) {
392
+ const lastUser = messages.map((m) => m.role).lastIndexOf("user");
393
+ const norm = (t: string) => t.replace(/\s+/g, " ").trim();
394
+ const want = norm(liveUser.text ?? "");
395
+ if (lastUser >= 0 && want && norm(messages[lastUser]!.text).startsWith(want.slice(0, 200))) {
396
+ messages = messages.slice(0, lastUser);
397
+ }
398
+ }
399
+ sendJson(res, 200, {
400
+ info: hub.info(key),
401
+ messages,
402
+ live,
403
+ seq,
404
+ pending: interactions.listPending(key),
405
+ allowedAgents: allowedAgentsFor(key),
406
+ serverTime: Date.now(),
407
+ });
408
+ return;
409
+ }
410
+ if (sub === "upload" && method === "POST") {
411
+ const data = await readBody(req, 50 * 1024 * 1024);
412
+ const name = url.searchParams.get("name") ?? undefined;
413
+ const type = req.headers["content-type"];
414
+ const id = crypto.randomBytes(8).toString("hex");
415
+ const attachment = saveBuffer({
416
+ key,
417
+ data,
418
+ subdir: id,
419
+ ...(name ? { name } : {}),
420
+ ...(type && type !== "application/octet-stream" ? { mediaType: type.split(";")[0]! } : {}),
421
+ });
422
+ uploads.set(id, { key, attachment, at: Date.now() });
423
+ sendJson(res, 200, { id, name: attachment.name, kind: attachment.kind, size: attachment.size });
424
+ return;
425
+ }
426
+ if (sub === "fork" && method === "POST") {
427
+ const body = await readJsonBody<{ anchor?: string }>(req);
428
+ const anchor = typeof body.anchor === "string" && body.anchor ? body.anchor : undefined;
429
+ const newKey = await hub.forkConversation(key, anchor);
430
+ sendJson(res, 200, { info: hub.info(newKey) });
431
+ return;
432
+ }
433
+ if (sub === "sessions" && method === "GET") {
434
+ sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
435
+ return;
436
+ }
437
+ if (sub === "models" && method === "GET") {
438
+ sendJson(res, 200, await hub.models(key));
439
+ return;
440
+ }
441
+ if (sub === "status" && method === "GET") {
442
+ sendJson(res, 200, { text: await hub.status(key), runtime: await hub.runtime(key) });
443
+ return;
444
+ }
445
+ if (sub === "action" && method === "POST") {
446
+ const body = await readJsonBody<{ action?: string; value?: string; cwd?: string }>(req);
447
+ const value = String(body.value ?? "");
448
+ let text: string;
449
+ switch (body.action) {
450
+ case "new":
451
+ text = await hub.newSession(key, value || undefined);
452
+ break;
453
+ case "fork":
454
+ text = await hub.fork(key);
455
+ break;
456
+ case "resume":
457
+ text = await hub.resume(key, value, body.cwd);
458
+ break;
459
+ case "model":
460
+ text = await hub.setModel(key, value);
461
+ break;
462
+ case "effort":
463
+ text = await hub.setEffort(key, value);
464
+ break;
465
+ case "agent":
466
+ text = await hub.setAgent(key, value, allowedAgentsFor(key));
467
+ break;
468
+ case "stop":
469
+ text = await hub.stop(key);
470
+ break;
471
+ case "cd":
472
+ text = await hub.cd(key, value);
473
+ break;
474
+ default:
475
+ throw new HttpError(400, "未知操作");
476
+ }
477
+ // 飞书会话上的操作,在飞书里也说一声
478
+ if (conv.channel !== "web") hub.notice(key, `💻 Web:${text}`);
479
+ sendJson(res, 200, { text, info: hub.info(key) });
480
+ return;
481
+ }
482
+ }
483
+
484
+ // ----- 设置 -----
485
+ if (p === "/api/config" && method === "GET") {
486
+ sendJson(res, 200, publicConfig());
487
+ return;
488
+ }
489
+ if (p === "/api/config" && method === "PUT") {
490
+ applyConfig(await readJsonBody<Partial<AppConfig>>(req));
491
+ sendJson(res, 200, publicConfig());
492
+ return;
493
+ }
494
+ // 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
495
+ if (p === "/api/quota" && method === "GET") {
496
+ const results = await Promise.all(
497
+ hub.enabledAgents().map(async (d) => {
498
+ if (!d.quota) return null;
499
+ try {
500
+ const quota = await Promise.race([
501
+ d.quota(),
502
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), 20_000).unref()),
503
+ ]);
504
+ return quota ? { agent: d.id, label: d.label, ...quota } : null;
505
+ } catch {
506
+ return null;
507
+ }
508
+ }),
509
+ );
510
+ sendJson(res, 200, { quotas: results.filter(Boolean), at: Date.now() });
511
+ return;
512
+ }
513
+ {
514
+ // 设置页的模型下拉框:不需要会话。
515
+ // 未启用的 agent 不去问 —— Codex 的 app-server 一问就会被拉起来常驻,违背「未启用」
516
+ const m = /^\/api\/agents\/([a-z0-9_-]+)\/models$/.exec(p);
517
+ if (m && method === "GET") {
518
+ const driver = hub.enabledAgents().find((d) => d.id === m[1]);
519
+ if (!driver) {
520
+ sendJson(res, 409, { error: "未启用,启用并保存后可下拉选择" });
521
+ return;
522
+ }
523
+ // defaults:没显式选模型 / 思考程度时实际会用的,输入框据此直接显示模型名而不是「默认」
524
+ const defaults = await driver.defaults().catch(() => ({}));
525
+ sendJson(res, 200, { models: await driver.catalog(), defaults });
526
+ return;
527
+ }
528
+ }
529
+ {
530
+ // 命令表(含隐藏项)。带 key 时按那条会话的目录读(项目里可能有自己的命令),
531
+ // Web 输入框补全用;不带 key 是设置页排序用。
532
+ const m = /^\/api\/agents\/([a-z0-9_-]+)\/commands$/.exec(p);
533
+ if (m && method === "GET") {
534
+ if (!(AGENT_IDS as readonly string[]).includes(m[1]!)) {
535
+ sendJson(res, 404, { error: `没有 agent「${m[1]}」` });
536
+ return;
537
+ }
538
+ const conv = getConversation(url.searchParams.get("key") ?? "");
539
+ const commands = await hub.commandCatalog(m[1]!, {
540
+ ...(conv ? { cwd: conv.cwd } : {}),
541
+ allowedAgents: hub.enabledAgents().map((d) => d.id),
542
+ });
543
+ sendJson(res, 200, { commands });
544
+ return;
545
+ }
546
+ }
547
+ if (p === "/api/password" && method === "POST") {
548
+ const body = await readJsonBody<{ old?: string; password?: string }>(req);
549
+ const left = takeAttempt(ip);
550
+ if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
551
+ if (!verifyPassword(String(body.old ?? ""), config().web.passwordHash)) throw new HttpError(403, "原密码不对");
552
+ clearAttempts(ip);
553
+ setPassword(String(body.password ?? ""));
554
+ setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
555
+ sendJson(res, 200, { ok: true });
556
+ return;
557
+ }
558
+ if (p === "/api/web-sessions" && method === "GET") {
559
+ sendJson(res, 200, listWebSessions());
560
+ return;
561
+ }
562
+ if (p === "/api/web-sessions/revoke" && method === "POST") {
563
+ const body = await readJsonBody<{ id?: string }>(req);
564
+ sendJson(res, 200, { ok: revokeSession(String(body.id ?? "")) });
565
+ return;
566
+ }
567
+ if (p === "/api/pairing" && method === "GET") {
568
+ sendJson(res, 200, { pending: listPending(), approved: listApproved() });
569
+ return;
570
+ }
571
+ if (p === "/api/pairing/approve" && method === "POST") {
572
+ const body = await readJsonBody<{ code?: string }>(req);
573
+ const result = approve(String(body.code ?? ""));
574
+ if (!result.ok) throw new HttpError(404, "配对码不存在或已过期");
575
+ sendJson(res, 200, result);
576
+ return;
577
+ }
578
+ if (p === "/api/pairing/revoke" && method === "POST") {
579
+ const body = await readJsonBody<{ channelId?: string; userId?: string }>(req);
580
+ sendJson(res, 200, { ok: revoke(String(body.channelId ?? ""), String(body.userId ?? "")) });
581
+ return;
582
+ }
583
+ if (p === "/api/claude/allowlist" && method === "GET") {
584
+ sendJson(res, 200, listAllowlist());
585
+ return;
586
+ }
587
+ if (p === "/api/claude/allowlist" && method === "PUT") {
588
+ const body = await readJsonBody<{ list?: string[] }>(req);
589
+ setAllowlist(Array.isArray(body.list) ? body.list.map(String) : []);
590
+ sendJson(res, 200, listAllowlist());
591
+ return;
592
+ }
593
+ if (p === "/api/feishu/slash" && method === "GET") {
594
+ sendJson(res, 200, { bots: feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, status: b.slash.status })) });
595
+ return;
596
+ }
597
+ if (p === "/api/feishu/slash/sync" && method === "POST") {
598
+ for (const b of feishuBots()) b.slash.request(0);
599
+ sendJson(res, 200, { ok: true });
600
+ return;
601
+ }
602
+ if (p === "/api/feishu/status" && method === "GET") {
603
+ sendJson(res, 200, feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, ...b.status })));
604
+ return;
605
+ }
606
+ if (p === "/api/feishu/test" && method === "POST") {
607
+ const body = await readJsonBody<{ id?: string; appId?: string; appSecret?: string; domain?: string }>(req);
608
+ const old = config().feishu.bots.find((b) => b.id === body.id);
609
+ const secret = !body.appSecret || body.appSecret.startsWith(MASK) ? old?.appSecret : body.appSecret;
610
+ if (!body.appId || !secret) throw new HttpError(400, "缺少 App ID 或 App Secret");
611
+ const probe = await new FeishuClient(body.appId, secret, body.domain === "lark" ? "lark" : "feishu", "测试").probe();
612
+ sendJson(res, 200, probe);
613
+ return;
614
+ }
615
+ if (p === "/api/feishu/register" && method === "POST") {
616
+ const body = await readJsonBody<{ name?: string; agents?: string[] }>(req);
617
+ sendJson(res, 200, await startRegistration(String(body.name ?? ""), Array.isArray(body.agents) ? body.agents : []));
618
+ return;
619
+ }
620
+ const regMatch = /^\/api\/feishu\/register\/([a-f0-9]+)$/.exec(p);
621
+ if (regMatch && method === "GET") {
622
+ const reg = registrations.get(regMatch[1]!);
623
+ if (!reg) throw new HttpError(404, "注册会话不存在或已过期");
624
+ sendJson(res, 200, { status: reg.status, message: reg.message, botId: reg.botId });
625
+ return;
626
+ }
627
+ if (p === "/api/system" && method === "GET") {
628
+ sendJson(res, 200, {
629
+ version: process.env.npm_package_version ?? "0.1.0",
630
+ node: process.version,
631
+ uptimeMs: Date.now() - startedAt,
632
+ pid: process.pid,
633
+ underSystemd: !!process.env.INVOCATION_ID,
634
+ });
635
+ return;
636
+ }
637
+ if (p === "/api/system/restart" && method === "POST") {
638
+ if (!process.env.INVOCATION_ID) throw new HttpError(400, "当前不是以系统服务运行,重启后不会自动拉起。请在终端里手动重启。");
639
+ sendJson(res, 200, { ok: true });
640
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 300);
641
+ return;
642
+ }
643
+
644
+ throw new HttpError(404, "没有这个接口");
645
+ }