hob-bot 1.0.3 → 1.1.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 (86) hide show
  1. package/README.md +75 -5
  2. package/README.zh-CN.md +75 -5
  3. package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
  4. package/dist/web/assets/index-B-8ISOr1.js +267 -0
  5. package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
  13. package/package.json +10 -2
  14. package/server/src/actions.ts +24 -57
  15. package/server/src/assistant-skills.ts +201 -0
  16. package/server/src/boot.ts +1 -0
  17. package/server/src/claude-status.ts +47 -0
  18. package/server/src/config.ts +3 -1
  19. package/server/src/delegations.ts +40 -12
  20. package/server/src/desktop-control.ts +152 -0
  21. package/server/src/desktop-events.ts +63 -0
  22. package/server/src/desktop-public.ts +43 -0
  23. package/server/src/desktop-tools.ts +161 -0
  24. package/server/src/desktop.ts +76 -0
  25. package/server/src/embedding.ts +52 -0
  26. package/server/src/files.ts +63 -43
  27. package/server/src/http.ts +334 -15
  28. package/server/src/jobs.ts +330 -59
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +1 -1
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +46 -154
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +42 -7
  43. package/server/src/router-activity.ts +4 -0
  44. package/server/src/router-tools.ts +132 -38
  45. package/server/src/router.ts +159 -19
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/thumbs.ts +49 -0
  51. package/server/src/transcript.ts +57 -4
  52. package/server/src/worker-tools.ts +14 -12
  53. package/shared/knowledge.ts +92 -0
  54. package/shared/mcp.ts +91 -0
  55. package/shared/media.ts +6 -0
  56. package/shared/plain-text.ts +31 -0
  57. package/shared/skills.ts +18 -0
  58. package/shared/sprites.ts +198 -0
  59. package/shared/types.ts +105 -20
  60. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  61. package/vendor/claude-bridge/LICENSE +21 -0
  62. package/vendor/claude-bridge/package.json +62 -0
  63. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  64. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  65. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  66. package/vendor/claude-bridge/src/attachments.ts +135 -0
  67. package/vendor/claude-bridge/src/config.ts +105 -0
  68. package/vendor/claude-bridge/src/convert.ts +222 -0
  69. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  70. package/vendor/claude-bridge/src/index.ts +2503 -0
  71. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  72. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  73. package/vendor/claude-bridge/src/models.ts +164 -0
  74. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  75. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  76. package/vendor/claude-bridge/src/query-state.ts +118 -0
  77. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  78. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  79. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  80. package/vendor/claude-bridge/src/skills.ts +20 -0
  81. package/vendor/claude-bridge/src/transcript.ts +86 -0
  82. package/vendor/claude-bridge/src/usage.ts +53 -0
  83. package/vendor/lore-pi-extension/package.json +1 -1
  84. package/vendor/lore-pi-extension/tools.ts +8 -5
  85. package/dist/web/assets/index-C72gAwgN.css +0 -1
  86. package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
@@ -1,5 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { Hono } from "hono";
5
5
  import { getCookie, setCookie } from "hono/cookie";
@@ -7,6 +7,7 @@ import { streamSSE } from "hono/streaming";
7
7
  import { serveStatic } from "@hono/node-server/serve-static";
8
8
  import type {
9
9
  AboutInfo,
10
+ ClaudeCodeSettings,
10
11
  Assistant,
11
12
  HobEvent,
12
13
  ImageKeyView,
@@ -22,12 +23,19 @@ import type {
22
23
  SettingsPatch,
23
24
  } from "../../shared/types.ts";
24
25
  import { parseSprite } from "../../shared/sprites.ts";
25
- import { answerApproval, createFromDraft, dismissDraft, mergeAssistant } from "./actions.ts";
26
+ import { answerApproval, archiveAssistant, createFromDraft, dismissDraft } from "./actions.ts";
26
27
  import { setPassword } from "./config.ts";
27
28
  import { newId } from "./ids.ts";
29
+ import { claudeStatus } from "./claude-status.ts";
30
+ import { checkDesktopUrl } from "./desktop.ts";
31
+ import { desktopProfile, openDesktopBrowser } from "./desktop-tools.ts";
28
32
  import { listJobFiles, lookupFile, MAX_TEXT_BYTES } from "./files.ts";
33
+ import { thumbnail } from "./thumbs.ts";
29
34
  import { localLoreHome, readLocalLore, readLocalLoreConnection } from "./imports.ts";
35
+ import { importMcpServers, listMcpImports, mcpView, patchMcpServer, removeMcpServer, saveMcpServer, testMcpServer } from "./mcp.ts";
36
+ import type { McpServerPatch, McpServerSave } from "../../shared/mcp.ts";
30
37
  import { checkImageSettings, generateImage, imageConfigured, imageKey, IMAGE_KEY_SECRET } from "./image.ts";
38
+ import { checkEmbeddingSettings, embed, embeddingKey, embeddingModel, EMBEDDING_KEY_SECRET } from "./embedding.ts";
31
39
  import { checkAssistantModels, checkModelsPatch } from "./models.ts";
32
40
  import { saveLoreConnection, testLoreConnection } from "./memory.ts";
33
41
  import { verifyPassword } from "./password.ts";
@@ -37,6 +45,7 @@ import { SEARCH_PROVIDERS } from "../../shared/search.ts";
37
45
  import { WebSearch } from "./search.ts";
38
46
  import { clampDuty } from "./text.ts";
39
47
  import { deleteOwnSkill, importSkills, listImportSkills, skillViews } from "./skills.ts";
48
+ import { archiveAssistantSkill, listAssistantSkills, rollbackAssistantSkill, setAssistantSkillEnabled } from "./assistant-skills.ts";
40
49
  import type { SkillImportSource } from "../../shared/skills.ts";
41
50
 
42
51
  const COOKIE = "hob";
@@ -52,7 +61,7 @@ function issueCookie(config: HobRuntime["config"]): string {
52
61
  return `${token}.${signature(config.cookieSecret, token)}`;
53
62
  }
54
63
 
55
- function validCookie(config: HobRuntime["config"], value: string | undefined): boolean {
64
+ export function validCookie(config: HobRuntime["config"], value: string | undefined): boolean {
56
65
  if (!value) return false;
57
66
  const dot = value.lastIndexOf(".");
58
67
  if (dot <= 0) return false;
@@ -69,6 +78,8 @@ function validCookie(config: HobRuntime["config"], value: string | undefined): b
69
78
 
70
79
  export function createApp(runtime: HobRuntime, options: { webDir?: string; home: string }): Hono {
71
80
  const { store, bus, router, jobs, routines, config } = runtime; const app = new Hono();
81
+ // ponytail: 每次取文件都扫一遍 job.json(几十上百个),慢了再缓存
82
+ const workDirs = () => store.jobs().flatMap((job) => (job.cwd ? [job.cwd] : []));
72
83
 
73
84
  app.use("/api/*", async (c, next) => {
74
85
  if (c.req.path === "/api/login") return next();
@@ -131,6 +142,66 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
131
142
  return c.json(next);
132
143
  });
133
144
 
145
+ app.get("/api/desktop", (c) => c.json(runtime.desktop.view()));
146
+ app.put("/api/desktop", async (c) => {
147
+ if (runtime.desktop.deployed) return c.json({ error: "云桌面由部署配置,不能在此修改。" }, 403);
148
+ const body = await c.req.json().catch(() => ({})) as { revision?: unknown; url?: unknown; enabled?: unknown; token?: unknown };
149
+ if (typeof body.revision !== "number" || typeof body.url !== "string" || typeof body.enabled !== "boolean" || (body.token !== undefined && typeof body.token !== "string")) return c.json({ error: "云桌面配置无效。" }, 400);
150
+ try {
151
+ const url = checkDesktopUrl(body.url);
152
+ const saved = runtime.settings.setDesktop(body.revision, { url, enabled: body.enabled });
153
+ if (!saved) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
154
+ if (body.token !== undefined) writeSecret(options.home, "desktop:token", body.token);
155
+ runtime.desktopEvents.restart();
156
+ return c.json(runtime.desktop.view());
157
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
158
+ });
159
+ app.put("/api/desktop/public", async (c) => {
160
+ const body = await c.req.json().catch(() => ({})) as { revision?: unknown; assistants?: unknown };
161
+ if (typeof body.revision !== "number" || !Array.isArray(body.assistants) || !body.assistants.every((id) => typeof id === "string" && !!runtime.store.assistant(id))) return c.json({ error: "助手列表无效。" }, 400);
162
+ const current = runtime.settings.settings.desktop ?? { url: "", enabled: false };
163
+ const saved = runtime.settings.setDesktop(body.revision, { ...current, publicAssistants: [...new Set(body.assistants)] });
164
+ if (!saved) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
165
+ return c.json(runtime.desktop.view());
166
+ });
167
+ const validScreen = (id: string) => id === "public" || runtime.store.assistants().some((assistant) => desktopProfile(assistant.id) === id);
168
+ app.get("/api/desktop/screens", (c) => c.json({ screens: runtime.desktopEvents.screens, profiles: Object.fromEntries(runtime.store.assistants().map((assistant) => [assistant.id, desktopProfile(assistant.id)])) }));
169
+ app.post("/api/desktop/screens/:id/start", async (c) => {
170
+ const id = c.req.param("id");
171
+ if (!/^[a-z0-9-]{1,64}$/.test(id)) return c.json({ error: "屏幕 id 无效。" }, 400);
172
+ if (!validScreen(id)) return c.json({ error: "屏幕不存在。" }, 404);
173
+ try { await runtime.desktop.request(`/api/screens/${id}/start`, { method: "POST" }); return c.json({ ok: true }); }
174
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); }
175
+ });
176
+ app.get("/api/desktop/control", (c) => {
177
+ const screen = c.req.query("screen") ?? "hob";
178
+ if (!validScreen(screen)) return c.json({ error: "屏幕不存在。" }, 404);
179
+ return c.json(runtime.desktopControl.state(c.req.query("client") ?? "", screen));
180
+ });
181
+ app.post("/api/desktop/control", async (c) => {
182
+ const body = await c.req.json().catch(() => ({})) as { client?: string; action?: string; screen?: string };
183
+ const screen = typeof body.screen === "string" ? body.screen : "hob";
184
+ if (!validScreen(screen)) return c.json({ error: "屏幕不存在。" }, 404);
185
+ try {
186
+ if (body.action === "take") runtime.desktopControl.take(body.client ?? "", screen);
187
+ else if (body.action === "release") runtime.desktopControl.release(body.client ?? "", screen);
188
+ else throw new Error("操作无效。");
189
+ return c.json(runtime.desktopControl.state(body.client ?? "", screen));
190
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 409); }
191
+ });
192
+ // 接管后要自己登录网站:在云桌面里打开某个助手的浏览器(每个助手的登录状态分开)
193
+ app.post("/api/desktop/browser", async (c) => {
194
+ const body = await c.req.json().catch(() => ({})) as { assistantId?: unknown };
195
+ const id = typeof body.assistantId === "string" ? body.assistantId : "";
196
+ if (!runtime.store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
197
+ try { await openDesktopBrowser(runtime, id); return c.json({ ok: true }); }
198
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); }
199
+ });
200
+ app.post("/api/desktop/test", async (c) => {
201
+ try { return c.json({ ok: true, health: await (await runtime.desktop.request("/api/health")).json() }); }
202
+ catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
203
+ });
204
+
134
205
  app.get("/api/skills", async (c) => {
135
206
  const memory = runtime.settings.memory();
136
207
  try { if (memory.provider === "lore" && !runtime.pi.loreSkillPaths.length) await runtime.pi.discoverLoreSkills(); }
@@ -152,6 +223,67 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
152
223
  catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
153
224
  });
154
225
 
226
+ // 设置 → MCP:列表(密钥不回传)。文件坏了返回错误,不当成空列表
227
+ app.get("/api/mcp", (c) => {
228
+ try {
229
+ return c.json(mcpView(options.home));
230
+ } catch (error) {
231
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
232
+ }
233
+ });
234
+
235
+ // 新建或改一个服务(名称在地址里;env / headers 逐键保留、替换、删除)
236
+ app.put("/api/mcp/:name", async (c) => {
237
+ const body = (await c.req.json().catch(() => ({}))) as McpServerSave;
238
+ try {
239
+ return c.json(await saveMcpServer(options.home, c.req.param("name"), body));
240
+ } catch (error) {
241
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
242
+ }
243
+ });
244
+
245
+ // 启用开关、提供方式、单独关掉的工具
246
+ app.patch("/api/mcp/:name", async (c) => {
247
+ const body = (await c.req.json().catch(() => ({}))) as McpServerPatch;
248
+ try {
249
+ return c.json(await patchMcpServer(options.home, c.req.param("name"), body));
250
+ } catch (error) {
251
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
252
+ }
253
+ });
254
+
255
+ app.delete("/api/mcp/:name", async (c) => {
256
+ try {
257
+ return c.json(await removeMcpServer(options.home, c.req.param("name")));
258
+ } catch (error) {
259
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
260
+ }
261
+ });
262
+
263
+ // 测试连接:连接 → 列工具 → 关闭;失败也是 200,原因在返回值里
264
+ app.post("/api/mcp/:name/test", async (c) => {
265
+ const body = (await c.req.json().catch(() => ({}))) as { refresh?: boolean };
266
+ try {
267
+ return c.json(await testMcpServer(options.home, c.req.param("name"), body.refresh !== false));
268
+ } catch (error) {
269
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
270
+ }
271
+ });
272
+
273
+ // 从本机导入:只读 pi / Claude Code / Cursor 的配置,同名跳过
274
+ app.get("/api/mcp/import", (c) => c.json(listMcpImports(options.home)));
275
+
276
+ app.post("/api/mcp/import", async (c) => {
277
+ const body = (await c.req.json().catch(() => ({}))) as { ids?: unknown };
278
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id): id is string => typeof id === "string") : [];
279
+ if (!ids.length) return c.json({ error: "请选择要导入的服务。" }, 400);
280
+ try {
281
+ return c.json(await importMcpServers(options.home, ids));
282
+ } catch (error) {
283
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
284
+ }
285
+ });
286
+
155
287
  // 设置 → 模型:引擎注册表里现在能用的聊天模型 + 名单里哪些已经不可用
156
288
  app.get("/api/models", async (c) => {
157
289
  const models = await runtime.models.get();
@@ -186,6 +318,24 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
186
318
  });
187
319
 
188
320
  // 设置 → 服务商:列表(Key 只报配没配)、引擎自带的服务商目录、整份设置
321
+ app.get("/api/providers/claude-status", async (c) => c.json(await claudeStatus(runtime.home, runtime.settings.settings.claude?.executable || undefined)));
322
+ app.get("/api/claude", async (c) => {
323
+ await runtime.providers.apply();
324
+ return c.json({ settings: runtime.settings.get(), status: await claudeStatus(runtime.home, runtime.settings.settings.claude?.executable || undefined) });
325
+ });
326
+ app.put("/api/claude", async (c) => {
327
+ const body = await c.req.json().catch(() => ({})) as { revision?: number; claude?: ClaudeCodeSettings };
328
+ try {
329
+ if (!body.claude) throw new Error("Claude Code 设置无效。");
330
+ const settings = runtime.settings.setClaude(body.revision ?? -1, body.claude);
331
+ if (!settings) return c.json({ error: "设置已更改,请刷新后重试。" }, 409);
332
+ await runtime.providers.apply();
333
+ router.releaseSessions();
334
+ return c.json({ settings, status: await claudeStatus(runtime.home, settings.settings.claude?.executable || undefined) });
335
+ } catch (error) {
336
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
337
+ }
338
+ });
189
339
  app.get("/api/providers", async (c) => c.json(await runtime.providers.view()));
190
340
 
191
341
  // 新建或改一个服务商;Key 可选,留空就沿用已存的
@@ -295,6 +445,34 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
295
445
  } catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
296
446
  });
297
447
 
448
+ app.get("/api/knowledge/embedding", (c) => c.json({ keyConfigured: Boolean(embeddingKey(options.home)) }));
449
+
450
+ app.put("/api/knowledge/embedding", async (c) => {
451
+ const body = await c.req.json().catch(() => ({})) as { revision?: number; embedding?: unknown; apiKey?: unknown };
452
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
453
+ try {
454
+ const previous = embeddingModel(runtime.settings.settings.knowledge?.embedding ?? { source: "off" });
455
+ const settings = runtime.settings.setEmbedding(body.revision, body.embedding);
456
+ if (!settings) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
457
+ const keyChanged = typeof body.apiKey === "string" && Boolean(body.apiKey.trim());
458
+ if (keyChanged) writeSecret(options.home, EMBEDDING_KEY_SECRET, String(body.apiKey).trim());
459
+ // 换模型要全部重算;只换 Key 时向量还能用,只重试之前失败的
460
+ if (previous !== embeddingModel(settings.settings.knowledge?.embedding ?? { source: "off" })) await runtime.knowledge.refreshIndexes(true);
461
+ else if (keyChanged) await runtime.knowledge.reindex();
462
+ return c.json({ settings, keyConfigured: Boolean(embeddingKey(options.home)) });
463
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
464
+ });
465
+
466
+ app.post("/api/knowledge/embedding/test", async (c) => {
467
+ const body = await c.req.json().catch(() => ({})) as { embedding?: unknown; apiKey?: unknown };
468
+ try {
469
+ const config = body.embedding ? checkEmbeddingSettings(body.embedding) : runtime.settings.settings.knowledge?.embedding ?? { source: "off" };
470
+ const key = typeof body.apiKey === "string" && body.apiKey.trim() ? body.apiKey.trim() : embeddingKey(options.home);
471
+ const vectors = await embed(config, key, ["知识库连接测试"]);
472
+ return c.json({ ok: true, dims: vectors[0]!.length });
473
+ } catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
474
+ });
475
+
298
476
  // 设置 → 图像生成:接口的 Key 配没配(Key 永不回显)
299
477
  app.get("/api/image", (c) => {
300
478
  const view: ImageKeyView = { keyConfigured: Boolean(imageKey(options.home)) };
@@ -315,6 +493,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
315
493
  if (typeof body.apiKey === "string" && body.apiKey.trim()) {
316
494
  writeSecret(options.home, IMAGE_KEY_SECRET, body.apiKey.trim());
317
495
  }
496
+ router.releaseSessions();
318
497
  const saved: ImageSettingsSaved = { settings, keyConfigured: Boolean(imageKey(options.home)) };
319
498
  return c.json(saved);
320
499
  });
@@ -417,6 +596,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
417
596
  const endpoint = typeof body.endpoint === "string" && body.endpoint ? body.endpoint : undefined;
418
597
  const result = await runtime.push.test(endpoint);
419
598
  if (endpoint && result.skipped === "no-devices") return c.json({ error: "这台设备尚未订阅推送" }, 404);
599
+ if (!result.sent && result.error) return c.json({ error: `发送失败:${result.error}` }, 502);
420
600
  return c.json(result);
421
601
  });
422
602
 
@@ -471,6 +651,42 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
471
651
  return c.json({ job, steps: jobs.transcript(job.id) });
472
652
  });
473
653
 
654
+ app.get("/api/assistants/:id/skills", (c) => {
655
+ const id = c.req.param("id");
656
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
657
+ try { return c.json(listAssistantSkills(options.home, id)); }
658
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
659
+ });
660
+ app.patch("/api/assistants/:id/skills/:name", async (c) => {
661
+ const id = c.req.param("id");
662
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
663
+ const body = await c.req.json().catch(() => ({})) as { enabled?: unknown };
664
+ if (typeof body.enabled !== "boolean") return c.json({ error: "启用状态无效" }, 400);
665
+ try {
666
+ const skill = setAssistantSkillEnabled(options.home, id, c.req.param("name"), body.enabled);
667
+ bus.emit({ type: "skills", assistantId: id });
668
+ return c.json(skill);
669
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
670
+ });
671
+ app.post("/api/assistants/:id/skills/:name/rollback", (c) => {
672
+ const id = c.req.param("id");
673
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
674
+ try {
675
+ const skill = rollbackAssistantSkill(options.home, id, c.req.param("name"));
676
+ bus.emit({ type: "skills", assistantId: id });
677
+ return c.json(skill);
678
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
679
+ });
680
+ app.delete("/api/assistants/:id/skills/:name", (c) => {
681
+ const id = c.req.param("id");
682
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
683
+ try {
684
+ archiveAssistantSkill(options.home, id, c.req.param("name"));
685
+ bus.emit({ type: "skills", assistantId: id });
686
+ return c.json({ ok: true });
687
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
688
+ });
689
+
474
690
  app.get("/api/assistants/:id/items", (c) => {
475
691
  const id = c.req.param("id");
476
692
  if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
@@ -494,7 +710,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
494
710
  files?: string[];
495
711
  };
496
712
  const text = (body.text ?? "").trim();
497
- if (!text) return c.json({ error: "消息内容为空" }, 400);
713
+ // 和 Telegram 一样,只发图片 / 文件不写字也行
714
+ if (!text && !body.files?.length) return c.json({ error: "消息内容为空" }, 400);
498
715
  const item = await router.send(id, {
499
716
  text,
500
717
  ...(body.quote ? { quote: body.quote } : {}),
@@ -536,6 +753,11 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
536
753
  bus.emit({ type: "profile", assistantId: id });
537
754
  return c.json({ ok: true, rule });
538
755
  }
756
+ if (body.knowledge !== undefined) {
757
+ if (!Array.isArray(body.knowledge) || body.knowledge.some((id) => typeof id !== "string")) return c.json({ error: "知识库绑定无效。" }, 400);
758
+ const collections = (await runtime.knowledge.list()).collections;
759
+ if (body.knowledge.some((id) => !collections.some((item) => item.id === id))) return c.json({ error: "知识库不存在。" }, 400);
760
+ }
539
761
  if (typeof body.sprite === "string" && body.sprite !== assistant.sprite) {
540
762
  const parsed = parseSprite(body.sprite);
541
763
  // custom 要有画法数据才收(画法在 prop 字段上,PATCH 不改它);否则渲染成空手火苗,不如拒绝
@@ -565,18 +787,19 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
565
787
  ...(models ? { models } : {}),
566
788
  ...(abilities ? { abilities } : {}),
567
789
  ...(typeof body.instructions === "string" ? { instructions: body.instructions } : {}),
790
+ ...(body.knowledge !== undefined ? { knowledge: [...new Set(body.knowledge)] } : {}),
568
791
  };
569
792
  store.saveAssistant(next);
570
- // 能派的模型写在 start_job 的说明里,换了就重开会话
571
- if (models) router.releaseSessions();
793
+ // 工具说明和注册取决于模型及助手能力,变化后重开会话
794
+ if (models || abilities) router.releaseSessions();
572
795
  const summary = router.summary(next);
573
796
  bus.emit({ type: "assistant", assistant: summary });
574
797
  return c.json({ assistant: summary });
575
798
  });
576
799
 
577
- // 并回 Hob:定时和规则转给主助手,对话和档案进归档,助手从列表移除
578
- app.post("/api/assistants/:id/merge", async (c) => {
579
- const result = await mergeAssistant(runtime, c.req.param("id"));
800
+ // 归档助手:档案、对话、规则和定时进归档,助手从列表移除
801
+ app.post("/api/assistants/:id/archive", async (c) => {
802
+ const result = await archiveAssistant(runtime, c.req.param("id"));
580
803
  return c.json(result.body as Record<string, unknown>, result.status as 200);
581
804
  });
582
805
 
@@ -648,6 +871,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
648
871
  instruction?: string;
649
872
  schedules?: string[];
650
873
  active?: boolean;
874
+ skill?: string | null;
651
875
  };
652
876
  try {
653
877
  const routine = routines.update(c.req.param("id"), body);
@@ -676,11 +900,18 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
676
900
  }
677
901
  });
678
902
 
903
+ app.post("/api/jobs/:id/sediment", (c) => {
904
+ const id = c.req.param("id");
905
+ if (!store.job(id)) return c.json({ error: "任务不存在" }, 404);
906
+ try { jobs.startSediment(id, "manual"); return c.json({ ok: true }); }
907
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 409); }
908
+ });
909
+
679
910
  app.post("/api/jobs/:id/reply", async (c) => {
680
911
  const body = (await c.req.json().catch(() => ({}))) as { text?: string };
681
912
  if (!body.text?.trim()) return c.json({ error: "消息是空的" }, 400);
682
913
  try {
683
- await jobs.reply(c.req.param("id"), body.text.trim());
914
+ await jobs.reply(c.req.param("id"), body.text.trim(), "user");
684
915
  return c.json({ ok: true });
685
916
  } catch (error) {
686
917
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
@@ -692,6 +923,85 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
692
923
  return c.json({ ok: true });
693
924
  });
694
925
 
926
+ app.get("/api/knowledge", async (c) => {
927
+ const limit = Number(c.req.query("limit"));
928
+ return c.json(await runtime.knowledge.list({ origin: c.req.query("origin"), collection: c.req.query("collection"), kind: c.req.query("kind"), assistantId: c.req.query("assistantId"), q: c.req.query("q"), before: c.req.query("before"), ...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}) }));
929
+ });
930
+
931
+ app.post("/api/knowledge/collections", async (c) => {
932
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown };
933
+ if (typeof body.name !== "string" || !body.name.trim()) return c.json({ error: "名称不能为空" }, 400);
934
+ const collection = await runtime.knowledge.createCollection(body.name.trim());
935
+ return c.json({ collection: { ...collection, count: 0 } });
936
+ });
937
+
938
+ app.patch("/api/knowledge/collections/:id", async (c) => {
939
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown };
940
+ if (typeof body.name !== "string" || !body.name.trim()) return c.json({ error: "名称不能为空" }, 400);
941
+ const collection = await runtime.knowledge.renameCollection(c.req.param("id"), body.name.trim());
942
+ if (!collection) return c.json({ error: "知识库不存在" }, 404);
943
+ const count = (await runtime.knowledge.list()).collections.find((item) => item.id === collection.id)?.count ?? 0;
944
+ return c.json({ collection: { ...collection, count } });
945
+ });
946
+
947
+ app.delete("/api/knowledge/collections/:id", async (c) => {
948
+ const deleted = await runtime.knowledge.deleteCollection(c.req.param("id"));
949
+ if (deleted === undefined) return c.json({ error: "知识库不存在" }, 404);
950
+ return c.json({ deleted });
951
+ });
952
+
953
+ app.get("/api/knowledge/entries/:id", async (c) => {
954
+ const raw = c.req.query("chunk");
955
+ if (raw !== undefined && !/^\d+$/.test(raw)) return c.json({ error: "段号无效" }, 400);
956
+ const view = await runtime.knowledge.entry(c.req.param("id"), raw === undefined ? undefined : Number(raw));
957
+ return view ? c.json(view) : c.json({ error: "条目不存在" }, 404);
958
+ });
959
+
960
+ app.post("/api/knowledge/collections/:id/reindex", async (c) => {
961
+ return await runtime.knowledge.reindex(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: "知识库不存在" }, 404);
962
+ });
963
+
964
+ app.patch("/api/knowledge/entries/:id", async (c) => {
965
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown; collections?: unknown };
966
+ if ((body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) || (body.collections !== undefined && (!Array.isArray(body.collections) || !body.collections.every((id) => typeof id === "string")))) return c.json({ error: "条目参数无效" }, 400);
967
+ try {
968
+ const entry = await runtime.knowledge.updateEntry(c.req.param("id"), { ...(typeof body.name === "string" ? { name: body.name.trim() } : {}), ...(Array.isArray(body.collections) ? { collections: body.collections as string[] } : {}) });
969
+ return entry ? c.json({ entry }) : c.json({ error: "条目不存在" }, 404);
970
+ } catch (error) {
971
+ if (error instanceof Error && error.message === "知识库不存在") return c.json({ error: error.message }, 404);
972
+ throw error;
973
+ }
974
+ });
975
+
976
+ app.delete("/api/knowledge/entries/:id", async (c) => {
977
+ return await runtime.knowledge.deleteEntry(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: "条目不存在" }, 404);
978
+ });
979
+
980
+ app.post("/api/knowledge/upload", async (c) => {
981
+ const collectionId = c.req.query("collection");
982
+ if (!collectionId || !(await runtime.knowledge.list()).collections.some((item) => item.id === collectionId)) return c.json({ error: "知识库不存在" }, 404);
983
+ const body = await c.req.parseBody({ all: true });
984
+ const files = Object.values(body).flatMap((value) => Array.isArray(value) ? value : [value]).filter((value): value is File => value instanceof File);
985
+ if (!files.length) return c.json({ error: "没有收到文件" }, 400);
986
+ if (files.some((file) => file.size > 100 * 1024 * 1024)) return c.json({ error: "文件超过 100 MB" }, 400);
987
+ const dir = join(options.home, "knowledge", "incoming");
988
+ mkdirSync(dir, { recursive: true });
989
+ const entries = [];
990
+ for (const file of files) {
991
+ const name = file.name.replace(/[/\\\\]/g, "_") || "file";
992
+ const path = join(dir, `${newId("u-")}-${name}`);
993
+ try {
994
+ writeFileSync(path, Buffer.from(await file.arrayBuffer()), { mode: 0o600 });
995
+ const entry = await runtime.knowledge.upload({ path, name }, collectionId);
996
+ if (entry) entries.push(entry);
997
+ } catch (error) {
998
+ if (error instanceof Error && error.message === "知识库不存在") return c.json({ error: error.message }, 404);
999
+ throw error;
1000
+ } finally { rmSync(path, { force: true }); }
1001
+ }
1002
+ return c.json({ entries });
1003
+ });
1004
+
695
1005
  app.post("/api/uploads", async (c) => {
696
1006
  const body = await c.req.parseBody({ all: true });
697
1007
  const uploads = join(options.home, "uploads");
@@ -709,9 +1019,9 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
709
1019
  return c.json({ files: saved.map((item) => item.path), file: saved[0] });
710
1020
  });
711
1021
 
712
- app.get("/api/files", (c) => {
1022
+ app.get("/api/files", async (c) => {
713
1023
  const requested = c.req.query("path");
714
- const lookup = requested ? lookupFile(options.home, requested) : null;
1024
+ const lookup = requested ? lookupFile(options.home, requested, workDirs()) : null;
715
1025
  const nosniff = { "x-content-type-options": "nosniff" };
716
1026
  if (!lookup) return c.json({ error: "缺 path" }, 400, nosniff);
717
1027
  if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status, nosniff);
@@ -728,6 +1038,11 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
728
1038
  const name = encodeURIComponent(info.name);
729
1039
  headers["content-disposition"] = `${c.req.query("inline") === "1" ? "inline" : "attachment"}; filename*=UTF-8''${name}`;
730
1040
  }
1041
+ // 对话里的图片只要缩略图(?thumb=1);缩不了 / 不值得缩就给原图
1042
+ if (c.req.query("thumb") === "1" && info.kind === "image") {
1043
+ const thumb = await thumbnail(options.home, info);
1044
+ if (thumb) return c.body(new Uint8Array(thumb.data), 200, { ...nosniff, "content-type": thumb.mime, "cache-control": "private, max-age=604800" });
1045
+ }
731
1046
  // 文本只拉 1 MB:预览面板对超大文件只显示前 1 MB,剩下的走下载
732
1047
  const whole = readFileSync(info.path);
733
1048
  const body = info.text && c.req.query("truncate") === "1" ? whole.subarray(0, MAX_TEXT_BYTES) : whole;
@@ -738,7 +1053,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
738
1053
  app.get("/api/files/meta", (c) => {
739
1054
  const requested = c.req.query("path");
740
1055
  if (!requested) return c.json({ error: "缺 path" }, 400);
741
- const lookup = lookupFile(options.home, requested);
1056
+ const lookup = lookupFile(options.home, requested, workDirs());
742
1057
  if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status);
743
1058
  const { info } = lookup;
744
1059
  const meta: FileMeta = {
@@ -754,11 +1069,15 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
754
1069
  return c.json(meta);
755
1070
  });
756
1071
 
757
- // 任务工作目录里的文件(递归、修改时间倒序、最多 200 个),只在这个任务的目录里找
1072
+ // 任务工作目录,一次只列一层。
758
1073
  app.get("/api/jobs/:id/files", (c) => {
759
1074
  const job = store.job(c.req.param("id"));
760
1075
  if (!job) return c.json({ error: "任务不存在" }, 404);
761
- return c.json(listJobFiles(store.workDir(job.id)));
1076
+ try {
1077
+ return c.json(listJobFiles(job.cwd ?? store.workDir(job.id), c.req.query("path")));
1078
+ } catch (error) {
1079
+ return c.json({ error: error instanceof Error ? error.message : "无法读取工作目录" }, 400);
1080
+ }
762
1081
  });
763
1082
 
764
1083
  app.get("/api/events", (c) => {