hob-bot 0.0.0-stage → 1.0.2

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
@@ -0,0 +1,802 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Hono } from "hono";
5
+ import { getCookie, setCookie } from "hono/cookie";
6
+ import { streamSSE } from "hono/streaming";
7
+ import { serveStatic } from "@hono/node-server/serve-static";
8
+ import type {
9
+ AboutInfo,
10
+ Assistant,
11
+ HobEvent,
12
+ ImageKeyView,
13
+ ImageSettingsSaved,
14
+ ImageTestResult,
15
+ AnswerApprovalBody,
16
+ FileMeta,
17
+ JobStatus,
18
+ ModelThinkingPatch,
19
+ ModelThinkingSaved,
20
+ ModelsView,
21
+ PushSubscriptionJson,
22
+ SettingsPatch,
23
+ } from "../../shared/types.ts";
24
+ import { parseSprite } from "../../shared/sprites.ts";
25
+ import { answerApproval, createFromDraft, dismissDraft, mergeAssistant } from "./actions.ts";
26
+ import { setPassword } from "./config.ts";
27
+ import { newId } from "./ids.ts";
28
+ import { listJobFiles, lookupFile, MAX_TEXT_BYTES } from "./files.ts";
29
+ import { localLoreHome, readLocalLore, readLocalLoreConnection } from "./imports.ts";
30
+ import { checkImageSettings, generateImage, imageConfigured, imageKey, IMAGE_KEY_SECRET } from "./image.ts";
31
+ import { checkAssistantModels, checkModelsPatch } from "./models.ts";
32
+ import { saveLoreConnection, testLoreConnection } from "./memory.ts";
33
+ import { verifyPassword } from "./password.ts";
34
+ import type { HobRuntime } from "./runtime.ts";
35
+ import { readSecrets, writeSecret } from "./secrets.ts";
36
+ import { SEARCH_PROVIDERS } from "../../shared/search.ts";
37
+ import { WebSearch } from "./search.ts";
38
+ import { clampDuty } from "./text.ts";
39
+ import { deleteOwnSkill, importSkills, listImportSkills, skillViews } from "./skills.ts";
40
+ import type { SkillImportSource } from "../../shared/skills.ts";
41
+
42
+ const COOKIE = "hob";
43
+ const THIRTY_DAYS = 30 * 24 * 3600;
44
+ const JOB_STATUSES: JobStatus[] = ["queued", "running", "waiting", "done", "stuck", "failed", "stopped"];
45
+
46
+ function signature(secret: string, token: string): string {
47
+ return createHmac("sha256", secret).update(token).digest("hex");
48
+ }
49
+
50
+ function issueCookie(config: HobRuntime["config"]): string {
51
+ const token = randomBytes(24).toString("hex");
52
+ return `${token}.${signature(config.cookieSecret, token)}`;
53
+ }
54
+
55
+ function validCookie(config: HobRuntime["config"], value: string | undefined): boolean {
56
+ if (!value) return false;
57
+ const dot = value.lastIndexOf(".");
58
+ if (dot <= 0) return false;
59
+ const token = value.slice(0, dot);
60
+ const given = value.slice(dot + 1);
61
+ const expected = signature(config.cookieSecret, token);
62
+ if (given.length !== expected.length) return false;
63
+ try {
64
+ return timingSafeEqual(Buffer.from(given, "utf8"), Buffer.from(expected, "utf8"));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ export function createApp(runtime: HobRuntime, options: { webDir?: string; home: string }): Hono {
71
+ const { store, bus, router, jobs, routines, config } = runtime; const app = new Hono();
72
+
73
+ app.use("/api/*", async (c, next) => {
74
+ if (c.req.path === "/api/login") return next();
75
+ if (!validCookie(config, getCookie(c, COOKIE))) return c.json({ error: "未登录" }, 401);
76
+ return next();
77
+ });
78
+
79
+ app.post("/api/login", async (c) => {
80
+ const body = (await c.req.json().catch(() => ({}))) as { password?: string };
81
+ if (!verifyPassword(runtime.config.passwordHash, body.password ?? "")) {
82
+ return c.json({ error: "密码不正确" }, 401);
83
+ }
84
+ setCookie(c, COOKIE, issueCookie(config), {
85
+ httpOnly: true,
86
+ sameSite: "Lax",
87
+ path: "/",
88
+ maxAge: THIRTY_DAYS,
89
+ });
90
+ return c.json({ ok: true });
91
+ });
92
+
93
+ app.post("/api/logout", (c) => {
94
+ setCookie(c, COOKIE, "", { httpOnly: true, sameSite: "Lax", path: "/", maxAge: 0 });
95
+ return c.json({ ok: true });
96
+ });
97
+
98
+ app.get("/api/me", (c) => c.json({ ok: true }));
99
+
100
+ app.get("/api/bootstrap", (c) => c.json(router.bootstrap()));
101
+
102
+ // 设置页:整份设置(带 revision)与按节合并的写入(revision 不对 → 409)
103
+ app.get("/api/settings", (c) => c.json(runtime.settings.get()));
104
+
105
+ app.put("/api/settings", async (c) => {
106
+ const body = (await c.req.json().catch(() => ({}))) as { revision?: number; patch?: SettingsPatch };
107
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
108
+ const patch = body.patch ?? {};
109
+ // VAPID subject 要用它,写得不像邮箱就直接拒
110
+ const email = patch.push?.email;
111
+ if (typeof email === "string" && email.trim() && !/^[^\s@]+@[^\s@]+$/.test(email.trim())) {
112
+ return c.json({ error: "邮箱格式无效" }, 400);
113
+ }
114
+ // 名单和角色要对照当前可用的模型校验(不可用的模型不能新加进名单)
115
+ try {
116
+ await checkModelsPatch(patch, runtime.settings.settings.models, runtime.models);
117
+ } catch (error) {
118
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
119
+ }
120
+ let next;
121
+ try {
122
+ next = runtime.settings.patch(body.revision, patch);
123
+ } catch (error) {
124
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
125
+ }
126
+ if (!next) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
127
+ // 改了设置立即生效:并发、无响应判定、定时时区都不需要重启
128
+ if (patch.models || patch.general?.showPage !== undefined) router.releaseSessions();
129
+ runtime.jobs.pump();
130
+ runtime.routines.reload();
131
+ return c.json(next);
132
+ });
133
+
134
+ app.get("/api/skills", async (c) => {
135
+ const memory = runtime.settings.memory();
136
+ try { if (memory.provider === "lore" && !runtime.pi.loreSkillPaths.length) await runtime.pi.discoverLoreSkills(); }
137
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 503); }
138
+ return c.json({ skills: skillViews(options.home, runtime.settings.settings.skills?.disabled ?? [], memory.provider === "lore" ? runtime.pi.loreSkillPaths : []), loreActive: memory.provider === "lore", loreUrl: memory.lore.baseUrl });
139
+ });
140
+ app.get("/api/skills/import/:source", (c) => {
141
+ try { return c.json(listImportSkills(options.home, c.req.param("source") as SkillImportSource)); }
142
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
143
+ });
144
+ app.post("/api/skills/import/:source", async (c) => {
145
+ const body = await c.req.json().catch(() => ({})) as { ids?: unknown; overwrite?: unknown };
146
+ if (!Array.isArray(body.ids) || !body.ids.every((id) => typeof id === "string") || typeof body.overwrite !== "boolean") return c.json({ error: "导入参数无效" }, 400);
147
+ try { return c.json(importSkills(options.home, c.req.param("source") as SkillImportSource, body.ids, body.overwrite)); }
148
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
149
+ });
150
+ app.delete("/api/skills/:id", (c) => {
151
+ try { deleteOwnSkill(options.home, c.req.param("id")); return c.json({ ok: true }); }
152
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
153
+ });
154
+
155
+ // 设置 → 模型:引擎注册表里现在能用的聊天模型 + 名单里哪些已经不可用
156
+ app.get("/api/models", async (c) => {
157
+ const models = await runtime.models.get();
158
+ const roster = runtime.settings.settings.models.roster;
159
+ const view: ModelsView = {
160
+ models,
161
+ roster: roster.map((entry) => ({ ...entry, ...(runtime.models.find(entry.ref) ? {} : { unavailable: true }) })),
162
+ };
163
+ return c.json(view);
164
+ });
165
+
166
+ // 设置 → 服务商 → 模型:存一个模型的自定义思考档(levels 为 null 就恢复默认)
167
+ app.put("/api/models/thinking", async (c) => {
168
+ const body = (await c.req.json().catch(() => ({}))) as Partial<ModelThinkingPatch>;
169
+ const ref = typeof body.ref === "string" ? body.ref.trim() : "";
170
+ if (!ref) return c.json({ error: "未指定模型。" }, 400);
171
+ // 只给已配置服务商里的模型存:存不到不存在的模型上
172
+ if (!(await runtime.providers.thinkingOf(ref))) {
173
+ return c.json({ error: `模型 ${ref} 不在已配置的服务商里。` }, 400);
174
+ }
175
+ let next;
176
+ try {
177
+ next = runtime.settings.setThinking(ref, body.levels);
178
+ } catch (error) {
179
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
180
+ }
181
+ // 存完马上套进引擎(兼容接口的模型配置 / 内置服务商的 modelOverrides),并把模型名单缓存失效
182
+ await runtime.providers.apply();
183
+ const thinking = await runtime.providers.thinkingOf(ref);
184
+ if (!thinking) return c.json({ error: `模型 ${ref} 不在已配置的服务商里。` }, 400);
185
+ return c.json({ settings: next, thinking } satisfies ModelThinkingSaved);
186
+ });
187
+
188
+ // 设置 → 服务商:列表(Key 只报配没配)、引擎自带的服务商目录、整份设置
189
+ app.get("/api/providers", async (c) => c.json(await runtime.providers.view()));
190
+
191
+ // 新建或改一个服务商;Key 可选,留空就沿用已存的
192
+ app.put("/api/providers/:id", async (c) => {
193
+ const body = await c.req.json().catch(() => ({}));
194
+ try {
195
+ const view = await runtime.providers.put(c.req.param("id"), body);
196
+ router.releaseSessions();
197
+ return c.json(view);
198
+ } catch (error) {
199
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
200
+ }
201
+ });
202
+
203
+ // 删服务商:Key 一起删,名单里用到它的模型变成不可用
204
+ app.delete("/api/providers/:id", async (c) => {
205
+ let view;
206
+ try {
207
+ view = await runtime.providers.remove(c.req.param("id"));
208
+ } catch (error) {
209
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
210
+ }
211
+ if (!view) return c.json({ error: "服务商不存在" }, 404);
212
+ router.releaseSessions();
213
+ return c.json(view);
214
+ });
215
+
216
+ app.post("/api/providers/test", async (c) => {
217
+ const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
218
+ try {
219
+ await runtime.providers.testConnection(body);
220
+ return c.json({ ok: true });
221
+ } catch (error) {
222
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
223
+ }
224
+ });
225
+
226
+ // 从兼容接口拉模型列表:失败也是 200 以外的具体原因
227
+ app.post("/api/providers/discover", async (c) => {
228
+ const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
229
+ try {
230
+ return c.json(await runtime.providers.discover(body));
231
+ } catch (error) {
232
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
233
+ }
234
+ });
235
+
236
+ // 从本机已有的配置导入服务商:只读来源文件,返回里没有 Key 明文
237
+ app.get("/api/providers/import", async (c) => c.json(await runtime.providers.importView()));
238
+
239
+ app.post("/api/providers/import", async (c) => {
240
+ const body = (await c.req.json().catch(() => ({}))) as { ids?: unknown };
241
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id): id is string => typeof id === "string") : [];
242
+ if (!ids.length) return c.json({ error: "请选择要导入的服务商。" }, 400);
243
+ try {
244
+ const view = await runtime.providers.importFromLocal(ids);
245
+ router.releaseSessions();
246
+ return c.json(view);
247
+ } catch (error) {
248
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
249
+ }
250
+ });
251
+
252
+ // 设置 → 记忆:提供者 + Lore 连接(Token 只写不读)
253
+ app.get("/api/memory", (c) => c.json(runtime.settings.memory()));
254
+
255
+ app.put("/api/memory", async (c) => {
256
+ const body = (await c.req.json().catch(() => ({}))) as { revision?: number; provider?: unknown };
257
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
258
+ let next;
259
+ try {
260
+ next = runtime.settings.setMemoryProvider(body.revision, body.provider);
261
+ } catch (error) {
262
+ // 选 Lore 但没配好:fail-closed,绝不静默回退到本地记忆
263
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
264
+ }
265
+ if (!next) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
266
+ // 新会话立即用新提供者:空闲的 Router 释放,在回复的等这一轮跑完再释放
267
+ router.releaseSessions();
268
+ return c.json(next);
269
+ });
270
+
271
+ app.get("/api/search", (c) => {
272
+ const keys = readSecrets(options.home);
273
+ return c.json({ providers: runtime.settings.settings.search.map((p) => ({ ...p, keyConfigured: Boolean(keys[`search:${p.name}`]) })) });
274
+ });
275
+
276
+ app.put("/api/search", async (c) => {
277
+ const body = await c.req.json().catch(() => ({})) as { revision?: number; providers?: unknown; keys?: Record<string, unknown> };
278
+ if (typeof body.revision !== "number" || !Array.isArray(body.providers)) return c.json({ error: "联网搜索配置无效。" }, 400);
279
+ const settings = runtime.settings.setSearch(body.revision, body.providers);
280
+ if (!settings) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
281
+ for (const name of SEARCH_PROVIDERS) if (typeof body.keys?.[name] === "string") writeSecret(options.home, `search:${name}`, (body.keys[name] as string).trim());
282
+ router.releaseSessions();
283
+ return c.json(settings);
284
+ });
285
+
286
+ app.post("/api/search/test", async (c) => {
287
+ const body = await c.req.json().catch(() => ({})) as { name?: string; baseUrl?: string; apiKey?: string };
288
+ const saved = runtime.settings.settings.search.find((p) => p.name === body.name);
289
+ const provider = saved && { ...saved, ...(typeof body.baseUrl === "string" ? { baseUrl: body.baseUrl.trim() } : {}) };
290
+ if (!provider) return c.json({ ok: false, error: "服务商不存在。" });
291
+ try {
292
+ const overrides = typeof body.apiKey === "string" && body.apiKey.trim() ? { [`search:${provider.name}`]: body.apiKey.trim() } : {};
293
+ const result = await new WebSearch(options.home, () => [{ ...provider, enabled: true }], undefined, overrides).search("hob");
294
+ return c.json(result.errors.length ? { ok: false, error: result.errors[0].error } : { ok: true, count: result.results.length });
295
+ } catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
296
+ });
297
+
298
+ // 设置 → 图像生成:接口的 Key 配没配(Key 永不回显)
299
+ app.get("/api/image", (c) => {
300
+ const view: ImageKeyView = { keyConfigured: Boolean(imageKey(options.home)) };
301
+ return c.json(view);
302
+ });
303
+
304
+ // 保存图像生成配置;带了新 Key 就写进 secrets.json(留空 = 沿用已保存的)
305
+ app.put("/api/image", async (c) => {
306
+ const body = (await c.req.json().catch(() => ({}))) as { revision?: number; image?: unknown; apiKey?: unknown };
307
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
308
+ let settings;
309
+ try {
310
+ settings = runtime.settings.setImage(body.revision, body.image);
311
+ } catch (error) {
312
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
313
+ }
314
+ if (!settings) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
315
+ if (typeof body.apiKey === "string" && body.apiKey.trim()) {
316
+ writeSecret(options.home, IMAGE_KEY_SECRET, body.apiKey.trim());
317
+ }
318
+ const saved: ImageSettingsSaved = { settings, keyConfigured: Boolean(imageKey(options.home)) };
319
+ return c.json(saved);
320
+ });
321
+
322
+ // 生成测试图片:真生成一张图(用户点了才发生),失败也是 200,原因原样给页面
323
+ app.post("/api/image/test", async (c) => {
324
+ const body = (await c.req.json().catch(() => ({}))) as { image?: unknown; apiKey?: unknown };
325
+ let config;
326
+ try {
327
+ config = body.image ? checkImageSettings(body.image) : runtime.settings.settings.image;
328
+ } catch (error) {
329
+ const failed: ImageTestResult = { ok: false, error: error instanceof Error ? error.message : String(error) };
330
+ return c.json(failed);
331
+ }
332
+ if (!imageConfigured(config)) {
333
+ const failed: ImageTestResult = {
334
+ ok: false,
335
+ error: "尚未配置图像生成:请在「设置 → 图像生成」中选择 OpenAI 兼容接口。",
336
+ };
337
+ return c.json(failed);
338
+ }
339
+ try {
340
+ const path = await generateImage(
341
+ {
342
+ home: options.home,
343
+ dir: join(options.home, "previews"),
344
+ config,
345
+ ...(typeof body.apiKey === "string" && body.apiKey.trim() ? { apiKey: body.apiKey.trim() } : {}),
346
+ },
347
+ { prompt: "一只橘猫,简笔画" },
348
+ );
349
+ const done: ImageTestResult = { ok: true, path };
350
+ return c.json(done);
351
+ } catch (error) {
352
+ const failed: ImageTestResult = { ok: false, error: error instanceof Error ? error.message : String(error) };
353
+ return c.json(failed);
354
+ }
355
+ });
356
+
357
+ app.put("/api/memory/lore", async (c) => {
358
+ const body = (await c.req.json().catch(() => ({}))) as { baseUrl?: unknown; token?: unknown };
359
+ try {
360
+ saveLoreConnection(options.home, body);
361
+ await testLoreConnection(options.home, {});
362
+ runtime.pi.loreSkillPaths = [];
363
+ } catch (error) {
364
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
365
+ }
366
+ router.releaseSessions();
367
+ return c.json(runtime.settings.memory());
368
+ });
369
+
370
+ app.post("/api/memory/lore-test", async (c) => {
371
+ const body = (await c.req.json().catch(() => ({}))) as { baseUrl?: unknown; token?: unknown };
372
+ return c.json(await testLoreConnection(options.home, body));
373
+ });
374
+
375
+ // 从本机已有的 Lore 配置导入(只读来源文件,Token 不回显)
376
+ app.get("/api/memory/lore/import", (c) => c.json(readLocalLore(localLoreHome())));
377
+
378
+ app.post("/api/memory/lore/import", async (c) => {
379
+ const local = readLocalLoreConnection(localLoreHome());
380
+ if (!local.view.found || !local.view.baseUrl) return c.json({ error: "未找到可导入的配置。" }, 400);
381
+ try {
382
+ saveLoreConnection(options.home, {
383
+ baseUrl: local.view.baseUrl,
384
+ ...(local.token ? { token: local.token } : {}),
385
+ });
386
+ await testLoreConnection(options.home, {});
387
+ runtime.pi.loreSkillPaths = [];
388
+ } catch (error) {
389
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
390
+ }
391
+ router.releaseSessions();
392
+ return c.json(runtime.settings.memory());
393
+ });
394
+
395
+ // 设置 → 通知:推送公开密钥 + 已订阅设备(VAPID 密钥和订阅在 HOB_HOME/push.json)
396
+ app.get("/api/push", (c) => c.json(runtime.push.view()));
397
+
398
+ app.post("/api/push/subscribe", async (c) => {
399
+ const body = (await c.req.json().catch(() => ({}))) as { subscription?: PushSubscriptionJson; label?: unknown };
400
+ try {
401
+ const device = runtime.push.subscribe(body.subscription as PushSubscriptionJson, typeof body.label === "string" ? body.label : "");
402
+ return c.json({ device, push: runtime.push.view() });
403
+ } catch (error) {
404
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
405
+ }
406
+ });
407
+
408
+ app.delete("/api/push/subscribe", async (c) => {
409
+ const body = (await c.req.json().catch(() => ({}))) as { endpoint?: unknown };
410
+ if (typeof body.endpoint !== "string" || !body.endpoint) return c.json({ error: "缺 endpoint" }, 400);
411
+ runtime.push.unsubscribe(body.endpoint);
412
+ return c.json(runtime.push.view());
413
+ });
414
+
415
+ app.post("/api/push/test", async (c) => {
416
+ const body = (await c.req.json().catch(() => ({}))) as { endpoint?: unknown };
417
+ const endpoint = typeof body.endpoint === "string" && body.endpoint ? body.endpoint : undefined;
418
+ const result = await runtime.push.test(endpoint);
419
+ if (endpoint && result.skipped === "no-devices") return c.json({ error: "这台设备尚未订阅推送" }, 404);
420
+ return c.json(result);
421
+ });
422
+
423
+ // 页面有没有在前台:只存内存,SSE 断掉就当不可见
424
+ app.post("/api/visibility", async (c) => {
425
+ const body = (await c.req.json().catch(() => ({}))) as { clientId?: unknown; visible?: unknown };
426
+ if (typeof body.clientId !== "string" || !body.clientId) return c.json({ error: "缺 clientId" }, 400);
427
+ runtime.push.setVisibility(body.clientId, body.visible === true);
428
+ return c.json({ ok: true });
429
+ });
430
+
431
+ app.post("/api/password", async (c) => {
432
+ const body = (await c.req.json().catch(() => ({}))) as { old?: string; new?: string };
433
+ if (!verifyPassword(runtime.config.passwordHash, body.old ?? "")) {
434
+ return c.json({ error: "旧密码不正确" }, 403);
435
+ }
436
+ const next = (body.new ?? "").trim();
437
+ if (next.length < 4) return c.json({ error: "新密码至少 4 位" }, 400);
438
+ runtime.config = setPassword(options.home, runtime.config, next);
439
+ return c.json({ ok: true });
440
+ });
441
+
442
+ // 设置 → 关于
443
+ app.get("/api/about", (c) => {
444
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: string };
445
+ const about: AboutInfo = { version: pkg.version ?? "0.0.0", home: options.home };
446
+ return c.json(about);
447
+ });
448
+
449
+ // 任务记录:全部任务,新的在前,可按助手 / 状态筛选
450
+ app.get("/api/jobs", (c) => {
451
+ const assistantId = c.req.query("assistantId");
452
+ const status = c.req.query("status");
453
+ if (status && !JOB_STATUSES.includes(status as JobStatus)) {
454
+ return c.json({ error: `无效的状态:${status}` }, 400);
455
+ }
456
+ const limit = Number(c.req.query("limit") ?? 30);
457
+ return c.json(
458
+ jobs.page({
459
+ ...(assistantId ? { assistantId } : {}),
460
+ ...(status ? { status: status as JobStatus } : {}),
461
+ ...(c.req.query("before") ? { before: c.req.query("before")! } : {}),
462
+ ...(Number.isFinite(limit) ? { limit } : {}),
463
+ }),
464
+ );
465
+ });
466
+
467
+ // 任务详情:从任务会话推导完整过程
468
+ app.get("/api/jobs/:id/transcript", (c) => {
469
+ const job = store.job(c.req.param("id"));
470
+ if (!job) return c.json({ error: "任务不存在" }, 404);
471
+ return c.json({ job, steps: jobs.transcript(job.id) });
472
+ });
473
+
474
+ app.get("/api/assistants/:id/items", (c) => {
475
+ const id = c.req.param("id");
476
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
477
+ const limit = Number(c.req.query("limit") ?? 50);
478
+ const before = c.req.query("before") ?? undefined;
479
+ return c.json(router.items(id, { ...(before ? { before } : {}), limit }));
480
+ });
481
+
482
+ app.get("/api/assistants/:id/profile", (c) => {
483
+ const id = c.req.param("id");
484
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
485
+ return c.json(router.profile(id));
486
+ });
487
+
488
+ app.post("/api/assistants/:id/messages", async (c) => {
489
+ const id = c.req.param("id");
490
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
491
+ const body = (await c.req.json().catch(() => ({}))) as {
492
+ text?: string;
493
+ quote?: { itemId: string; text: string };
494
+ files?: string[];
495
+ };
496
+ const text = (body.text ?? "").trim();
497
+ if (!text) return c.json({ error: "消息内容为空" }, 400);
498
+ const item = await router.send(id, {
499
+ text,
500
+ ...(body.quote ? { quote: body.quote } : {}),
501
+ ...(body.files?.length ? { files: body.files } : {}),
502
+ });
503
+ return c.json({ item }, 202);
504
+ });
505
+
506
+ app.post("/api/assistants/:id/stop", async (c) => {
507
+ await router.stop(c.req.param("id"));
508
+ return c.json({ ok: true });
509
+ });
510
+
511
+ app.post("/api/assistants/:id/read", (c) => {
512
+ router.read(c.req.param("id"));
513
+ return c.json({ ok: true });
514
+ });
515
+
516
+ app.patch("/api/assistants/:id", async (c) => {
517
+ const id = c.req.param("id");
518
+ const assistant = store.assistant(id);
519
+ if (!assistant) return c.json({ error: "助手不存在" }, 404);
520
+ const body = (await c.req.json().catch(() => ({}))) as Partial<Assistant> & { rule?: { id?: unknown; text?: unknown; mode?: unknown } };
521
+ if (body.rule !== undefined) {
522
+ const edit = body.rule;
523
+ if (!edit || typeof edit !== "object" || (edit.id !== undefined && (typeof edit.id !== "string" || !edit.id))) return c.json({ error: "规则无效。" }, 400);
524
+ if ((edit.id === undefined || edit.text !== undefined) && (typeof edit.text !== "string" || !edit.text.trim() || edit.text.trim().length > 1000)) return c.json({ error: "规则内容不能为空或超过 1000 字。" }, 400);
525
+ if ((edit.id === undefined || edit.mode !== undefined) && edit.mode !== "ask" && edit.mode !== "allow") return c.json({ error: "规则模式无效。" }, 400);
526
+ const rules = store.rules(id);
527
+ const existing = edit.id === undefined ? undefined : rules.find((item) => item.id === edit.id);
528
+ if (edit.id !== undefined && !existing) return c.json({ error: "规则不存在。" }, 404);
529
+ const text = typeof edit.text === "string" ? edit.text.trim() : existing!.text;
530
+ if (rules.some((item) => item.id !== edit.id && item.text === text)) return c.json({ error: "规则内容已存在。" }, 400);
531
+ const mode = edit.mode === "ask" || edit.mode === "allow" ? edit.mode : existing!.mode;
532
+ const rule = { id: existing?.id ?? newId("r-"), text, mode, changedAt: new Date().toISOString() };
533
+ if (existing) Object.assign(existing, rule);
534
+ else rules.push(rule);
535
+ store.saveRules(id, rules);
536
+ bus.emit({ type: "profile", assistantId: id });
537
+ return c.json({ ok: true, rule });
538
+ }
539
+ if (typeof body.sprite === "string" && body.sprite !== assistant.sprite) {
540
+ const parsed = parseSprite(body.sprite);
541
+ // custom 要有画法数据才收(画法在 prop 字段上,PATCH 不改它);否则渲染成空手火苗,不如拒绝
542
+ if (!parsed || (parsed.prop === "custom" && !assistant.prop)) return c.json({ error: "头像无效。" }, 400);
543
+ }
544
+ let models;
545
+ let abilities;
546
+ try {
547
+ await runtime.models.get();
548
+ ({ models, abilities } = checkAssistantModels(
549
+ { ...(body.models ? { models: body.models } : {}), ...(body.abilities ? { abilities: body.abilities } : {}) },
550
+ runtime.settings.settings.models.roster,
551
+ runtime.models,
552
+ ));
553
+ } catch (error) {
554
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
555
+ }
556
+ const next: Assistant = {
557
+ ...assistant,
558
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
559
+ ...(typeof body.duty === "string" ? { duty: clampDuty(body.duty) } : {}),
560
+ ...(typeof body.dutyLong === "string" ? { dutyLong: body.dutyLong } : {}),
561
+ ...(typeof body.sprite === "string" ? { sprite: body.sprite } : {}),
562
+ ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}),
563
+ ...(typeof body.hidden === "boolean" ? { hidden: body.hidden } : {}),
564
+ ...(typeof body.notify === "boolean" ? { notify: body.notify } : {}),
565
+ ...(models ? { models } : {}),
566
+ ...(abilities ? { abilities } : {}),
567
+ ...(typeof body.instructions === "string" ? { instructions: body.instructions } : {}),
568
+ };
569
+ store.saveAssistant(next);
570
+ // 能派的模型写在 start_job 的说明里,换了就重开会话
571
+ if (models) router.releaseSessions();
572
+ const summary = router.summary(next);
573
+ bus.emit({ type: "assistant", assistant: summary });
574
+ return c.json({ assistant: summary });
575
+ });
576
+
577
+ // 并回 Hob:定时和规则转给主助手,对话和档案进归档,助手从列表移除
578
+ app.post("/api/assistants/:id/merge", async (c) => {
579
+ const result = await mergeAssistant(runtime, c.req.param("id"));
580
+ return c.json(result.body as Record<string, unknown>, result.status as 200);
581
+ });
582
+
583
+ app.delete("/api/assistants/:id/rules/:rid", (c) => {
584
+ const id = c.req.param("id");
585
+ const rules = store.rules(id).filter((rule) => rule.id !== c.req.param("rid"));
586
+ store.saveRules(id, rules);
587
+ bus.emit({ type: "profile", assistantId: id });
588
+ return c.json({ ok: true });
589
+ });
590
+
591
+ app.post("/api/delegations/:id/retry", async (c) => {
592
+ const record = store.delegation(c.req.param("id"));
593
+ if (!record) return c.json({ error: "委派不存在" }, 404);
594
+ if (record.status !== "interrupted") return c.json({ error: "只有已中断的委派可以重新转交" }, 400);
595
+ try {
596
+ return c.json({ delegation: await runtime.delegations.start(record.to, record.text) });
597
+ } catch (error) {
598
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
599
+ }
600
+ });
601
+
602
+ app.post("/api/approvals/:id", async (c) => {
603
+ const body = (await c.req.json().catch(() => ({}))) as AnswerApprovalBody;
604
+ if (body.key === undefined && body.text === undefined) return c.json({ error: "缺答案:key 或 text 二选一" }, 400);
605
+ const result = await answerApproval(runtime, c.req.param("id"), {
606
+ ...(body.key !== undefined ? { key: body.key } : {}),
607
+ ...(body.text !== undefined ? { text: body.text } : {}),
608
+ });
609
+ return c.json(result.body as Record<string, unknown>, result.status as 200);
610
+ });
611
+
612
+ app.post("/api/drafts/:id/create", async (c) => {
613
+ const result = await createFromDraft(runtime, c.req.param("id"));
614
+ return c.json(result.body as Record<string, unknown>, result.status as 200);
615
+ });
616
+
617
+ app.post("/api/drafts/:id/dismiss", (c) => {
618
+ const result = dismissDraft(runtime, c.req.param("id"));
619
+ return c.json(result.body as Record<string, unknown>, result.status as 200);
620
+ });
621
+
622
+ app.post("/api/routines", async (c) => {
623
+ const body = (await c.req.json().catch(() => ({}))) as {
624
+ assistantId?: string;
625
+ name?: string;
626
+ instruction?: string;
627
+ schedules?: string[];
628
+ };
629
+ if (!body.assistantId || !body.name || !body.instruction || !body.schedules?.length) {
630
+ return c.json({ error: "缺字段:assistantId、name、instruction、schedules" }, 400);
631
+ }
632
+ try {
633
+ const routine = routines.create({
634
+ assistantId: body.assistantId,
635
+ name: body.name,
636
+ instruction: body.instruction,
637
+ schedules: body.schedules,
638
+ });
639
+ return c.json({ routine });
640
+ } catch (error) {
641
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
642
+ }
643
+ });
644
+
645
+ app.patch("/api/routines/:id", async (c) => {
646
+ const body = (await c.req.json().catch(() => ({}))) as {
647
+ name?: string;
648
+ instruction?: string;
649
+ schedules?: string[];
650
+ active?: boolean;
651
+ };
652
+ try {
653
+ const routine = routines.update(c.req.param("id"), body);
654
+ return c.json({ routine });
655
+ } catch (error) {
656
+ const message = error instanceof Error ? error.message : String(error);
657
+ return c.json({ error: message }, message.includes("不存在") ? 404 : 400);
658
+ }
659
+ });
660
+
661
+ app.delete("/api/routines/:id", (c) => {
662
+ try {
663
+ routines.remove(c.req.param("id"));
664
+ return c.json({ ok: true });
665
+ } catch (error) {
666
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 404);
667
+ }
668
+ });
669
+
670
+ app.post("/api/routines/:id/run", async (c) => {
671
+ try {
672
+ const job = await routines.runNow(c.req.param("id"));
673
+ return c.json({ job });
674
+ } catch (error) {
675
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 404);
676
+ }
677
+ });
678
+
679
+ app.post("/api/jobs/:id/reply", async (c) => {
680
+ const body = (await c.req.json().catch(() => ({}))) as { text?: string };
681
+ if (!body.text?.trim()) return c.json({ error: "消息是空的" }, 400);
682
+ try {
683
+ await jobs.reply(c.req.param("id"), body.text.trim());
684
+ return c.json({ ok: true });
685
+ } catch (error) {
686
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
687
+ }
688
+ });
689
+
690
+ app.post("/api/jobs/:id/stop", async (c) => {
691
+ await jobs.stop(c.req.param("id"));
692
+ return c.json({ ok: true });
693
+ });
694
+
695
+ app.post("/api/uploads", async (c) => {
696
+ const body = await c.req.parseBody({ all: true });
697
+ const uploads = join(options.home, "uploads");
698
+ mkdirSync(uploads, { recursive: true });
699
+ const saved: { path: string; name: string }[] = [];
700
+ const values = Object.values(body).flatMap((value) => (Array.isArray(value) ? value : [value]));
701
+ for (const value of values) {
702
+ if (!(value instanceof File)) continue;
703
+ const safeName = value.name.replace(/[/\\]/g, "_") || "file";
704
+ const path = join(uploads, `${newId("u-")}-${safeName}`);
705
+ writeFileSync(path, Buffer.from(await value.arrayBuffer()));
706
+ saved.push({ path, name: safeName });
707
+ }
708
+ if (!saved.length) return c.json({ error: "没有收到文件" }, 400);
709
+ return c.json({ files: saved.map((item) => item.path), file: saved[0] });
710
+ });
711
+
712
+ app.get("/api/files", (c) => {
713
+ const requested = c.req.query("path");
714
+ const lookup = requested ? lookupFile(options.home, requested) : null;
715
+ const nosniff = { "x-content-type-options": "nosniff" };
716
+ if (!lookup) return c.json({ error: "缺 path" }, 400, nosniff);
717
+ if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status, nosniff);
718
+ const { info } = lookup;
719
+ // 能跑脚本的类型(html / svg / xml):默认当附件下载,只有预览面板带 ?inline=1 才内联,并且始终加 sandbox CSP
720
+ const scriptable = info.kind === "html" || info.kind === "svg" || info.kind === "xml";
721
+ // 代码/纯文本按扩展名只有 octet-stream,不带 charset 会被浏览器当 latin1 解码,中文变乱码
722
+ const mime = info.text && !info.mime.includes("charset") ? `${info.mime}; charset=utf-8` : info.mime;
723
+ const headers: Record<string, string> = { ...nosniff, "content-type": mime };
724
+ if (scriptable) {
725
+ headers["content-security-policy"] = info.kind === "html"
726
+ ? info.page && c.req.query("inline") === "1" ? "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" : "sandbox allow-scripts"
727
+ : "sandbox";
728
+ const name = encodeURIComponent(info.name);
729
+ headers["content-disposition"] = `${c.req.query("inline") === "1" ? "inline" : "attachment"}; filename*=UTF-8''${name}`;
730
+ }
731
+ // 文本只拉 1 MB:预览面板对超大文件只显示前 1 MB,剩下的走下载
732
+ const whole = readFileSync(info.path);
733
+ const body = info.text && c.req.query("truncate") === "1" ? whole.subarray(0, MAX_TEXT_BYTES) : whole;
734
+ return c.body(new Uint8Array(body), 200, headers);
735
+ });
736
+
737
+ // 预览面板要的元数据:大小、修改时间、类型、是不是文本
738
+ app.get("/api/files/meta", (c) => {
739
+ const requested = c.req.query("path");
740
+ if (!requested) return c.json({ error: "缺 path" }, 400);
741
+ const lookup = lookupFile(options.home, requested);
742
+ if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status);
743
+ const { info } = lookup;
744
+ const meta: FileMeta = {
745
+ path: info.path,
746
+ name: info.name,
747
+ size: info.size,
748
+ modifiedAt: info.modifiedAt,
749
+ mime: info.mime,
750
+ kind: info.kind,
751
+ text: info.text,
752
+ ...(info.page ? { page: true } : {}),
753
+ };
754
+ return c.json(meta);
755
+ });
756
+
757
+ // 任务工作目录里的文件(递归、修改时间倒序、最多 200 个),只在这个任务的目录里找
758
+ app.get("/api/jobs/:id/files", (c) => {
759
+ const job = store.job(c.req.param("id"));
760
+ if (!job) return c.json({ error: "任务不存在" }, 404);
761
+ return c.json(listJobFiles(store.workDir(job.id)));
762
+ });
763
+
764
+ app.get("/api/events", (c) => {
765
+ // 页面在连接上带自己的 clientId:连接断了就把这台设备的可见性清掉
766
+ const clientId = c.req.query("clientId") ?? "";
767
+ return streamSSE(c, async (stream) => {
768
+ await stream.write(": ok\n\n");
769
+ const detach = runtime.push.attachClient(clientId);
770
+ const unsubscribe = bus.subscribe((event: HobEvent) => {
771
+ void stream.writeSSE({ data: JSON.stringify(event) }).catch(() => undefined);
772
+ });
773
+ const heartbeat = setInterval(() => {
774
+ void stream.write(": ping\n\n").catch(() => undefined);
775
+ }, 25_000);
776
+ heartbeat.unref?.();
777
+ try {
778
+ await new Promise<void>((resolveDone) => {
779
+ c.req.raw.signal.addEventListener("abort", () => resolveDone());
780
+ });
781
+ } finally {
782
+ clearInterval(heartbeat);
783
+ unsubscribe();
784
+ detach();
785
+ }
786
+ });
787
+ });
788
+
789
+ const webDir = options.webDir;
790
+ if (webDir && existsSync(join(webDir, "index.html"))) {
791
+ // iOS 添加到主屏幕时还会去根目录要 /apple-touch-icon(-180x180)(-precomposed).png,一律给同一张
792
+ app.get("/:icon{apple-touch-icon.*\\.png}", serveStatic({ path: join(webDir, "icons/apple-touch-icon-180.png") }));
793
+ app.use("*", serveStatic({ root: webDir }));
794
+ // 带扩展名的路径找不到就是 404:/favicon.ico、/apple-touch-icon.png 这类请求拿到网页会被当成坏图标
795
+ app.get("*", async (c, next) => (/\.[a-z0-9]+$/i.test(c.req.path) ? c.notFound() : next()));
796
+ app.get("*", serveStatic({ path: join(webDir, "index.html") }));
797
+ } else {
798
+ app.get("/", (c) => c.text("Hob 服务端正在运行。前端尚未构建:请执行 npm run build。"));
799
+ }
800
+
801
+ return app;
802
+ }