hob-bot 1.0.2 → 1.0.4

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 (88) hide show
  1. package/README.md +0 -14
  2. package/README.zh-CN.md +0 -14
  3. package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/web/assets/index-BCGEZvgg.css +1 -0
  63. package/dist/web/assets/index-BqkIAlk9.js +258 -0
  64. package/dist/web/assets/math-C-EwFotM.css +1 -0
  65. package/dist/web/assets/math-zZ4JA8eM.js +257 -0
  66. package/dist/web/index.html +2 -2
  67. package/dist/web/mock/audio.mp3 +0 -0
  68. package/dist/web/mock/photo.jpg +0 -0
  69. package/dist/web/mock/photo2.jpg +0 -0
  70. package/dist/web/mock/video.mp4 +0 -0
  71. 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
  72. package/package.json +6 -2
  73. package/server/src/actions.ts +8 -1
  74. package/server/src/delegations.ts +20 -2
  75. package/server/src/http.ts +9 -2
  76. package/server/src/jobs.ts +52 -20
  77. package/server/src/prompt.ts +2 -0
  78. package/server/src/push.ts +3 -2
  79. package/server/src/router-activity.ts +47 -0
  80. package/server/src/router.ts +53 -7
  81. package/server/src/settings.ts +2 -1
  82. package/server/src/thumbs.ts +49 -0
  83. package/shared/media.ts +6 -0
  84. package/shared/plain-text.ts +31 -0
  85. package/shared/sprites.ts +215 -0
  86. package/shared/types.ts +8 -0
  87. package/dist/web/assets/index-D88l35PH.css +0 -1
  88. package/dist/web/assets/index-DUs8nN3E.js +0 -218
@@ -23,8 +23,8 @@
23
23
  <meta name="apple-mobile-web-app-title" content="Hob" />
24
24
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
25
25
  <title>Hob</title>
26
- <script type="module" crossorigin src="/assets/index-DUs8nN3E.js"></script>
27
- <link rel="stylesheet" crossorigin href="/assets/index-D88l35PH.css">
26
+ <script type="module" crossorigin src="/assets/index-BqkIAlk9.js"></script>
27
+ <link rel="stylesheet" crossorigin href="/assets/index-BCGEZvgg.css">
28
28
  </head>
29
29
  <body>
30
30
  <div id="root"></div>
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,34 @@
1
+ # 火星日落为什么是蓝色的
2
+
3
+ 火星大气很稀薄,但飘着大量**细小的尘埃**。尘埃颗粒的大小和蓝光波长相近,蓝光会被集中散射到太阳周围,所以日落时太阳附近是一圈蓝。
4
+
5
+ ## 和地球对比
6
+
7
+ | 项目 | 地球 | 火星 |
8
+ | --- | --- | --- |
9
+ | 大气压 | 1013 hPa | 约 6 hPa |
10
+ | 主要散射体 | 气体分子 | 尘埃颗粒 |
11
+ | 白天天空 | 蓝色 | 黄褐色 |
12
+ | 日落颜色 | 红橙 | **蓝色** |
13
+
14
+ ## 要点
15
+
16
+ - 尘埃颗粒直径约 1.5 μm,对蓝光的**前向散射**最强
17
+ - 太阳越低,光穿过的尘埃越多,蓝晕越明显
18
+ - 好奇号在 2015 年拍到过完整过程
19
+
20
+ 1. 白天:尘埃把红光散到全天空,天空发黄
21
+ 2. 日落:前向散射的蓝光留在太阳周围
22
+
23
+ > 数据来自 NASA 好奇号任务公开资料。
24
+
25
+ ### 示意图源码
26
+
27
+ ```mermaid
28
+ graph LR
29
+ 太阳 --> 尘埃 --> 蓝晕
30
+ ```
31
+
32
+ ---
33
+
34
+ 验证状态:**明确 token 被拒**,`401 Unauthorized`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hob-bot",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A self-hosted personal assistant that runs real work as background jobs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,11 @@
37
37
  "@juicesharp/rpiv-config": "2.15.0",
38
38
  "@juicesharp/rpiv-web-tools": "2.15.0",
39
39
  "croner": "^9.0.0",
40
+ "github-markdown-css": "^5.9.0",
40
41
  "hono": "^4.6.0",
41
- "marked": "^15.0.0",
42
+ "katex": "^0.19.1",
43
+ "marked": "^18.1.3",
44
+ "marked-cjk-friendly": "^0.1.3",
42
45
  "pi-memory": "0.4.2",
43
46
  "react": "^19.0.0",
44
47
  "react-dom": "^19.0.0",
@@ -47,6 +50,7 @@
47
50
  "ws": "^8.18.0"
48
51
  },
49
52
  "devDependencies": {
53
+ "@types/katex": "^0.16.8",
50
54
  "@types/node": "^22.0.0",
51
55
  "@types/react": "^19.0.0",
52
56
  "@types/react-dom": "^19.0.0",
@@ -34,6 +34,9 @@ export async function answerApproval(runtime: HobRuntime, approvalId: string, in
34
34
  answeredAt: new Date().toISOString(),
35
35
  };
36
36
  store.saveApproval(answered);
37
+ // 转交里 Router 问的:答案回到目标助手接着办,办完才算转交结束(任务里的提问由任务自己接着跑)
38
+ const delegation = approval.source === "job" ? undefined : store.delegations().find((item) => item.approvalIds?.includes(approval.id));
39
+ const delegationId = delegation && runtime.delegations.resume(delegation.id) ? delegation.id : undefined;
37
40
  bus.emit({ type: "approval", approval: answered });
38
41
 
39
42
  if (option?.always) {
@@ -59,7 +62,11 @@ export async function answerApproval(runtime: HobRuntime, approvalId: string, in
59
62
  // 回给模型:自己写的那句要说明是用户写的、没选给出的选项
60
63
  const answer = option ? `用户选择了:${option.label}` : `用户没有选给出的选项,而是回复:${text}`;
61
64
  if (approval.source === "job" && approval.jobId) jobs.resolveAsk(answered, answer);
62
- else await router.answerEvent(approval.assistantId, option ? `用户选择了 ${option.key}:${option.label}` : answer);
65
+ else {
66
+ const reply = option ? `用户选择了 ${option.key}:${option.label}` : answer;
67
+ if (delegationId) void router.relay(approval.assistantId, reply, delegationId).catch(() => runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
68
+ else await router.answerEvent(approval.assistantId, reply);
69
+ }
63
70
 
64
71
  return { status: 200, body: { approval: answered } };
65
72
  }
@@ -32,7 +32,7 @@ export class DelegationManager {
32
32
  throw error;
33
33
  }
34
34
  this.runtime.bus.emit({ type: "delegation", delegation });
35
- void this.runtime.router.relay(to, text, delegation.id).catch((error: unknown) => {
35
+ void this.runtime.router.relay(to, `转自 Hob:${text}`, delegation.id).catch((error: unknown) => {
36
36
  this.turnEnded(delegation.id, error instanceof Error ? error.message : String(error), true);
37
37
  });
38
38
  return delegation;
@@ -54,10 +54,26 @@ export class DelegationManager {
54
54
  turnEnded(id: string, reply: string, failed = false): void {
55
55
  const record = this.runtime.store.delegation(id);
56
56
  if (!record || record.turnFinished) return;
57
- this.runtime.store.saveDelegation({ ...record, reply, turnFinished: true, ...(failed ? { turnFailed: true } : {}) });
57
+ const replies = [record.reply, reply].filter(Boolean).join("\n");
58
+ this.runtime.store.saveDelegation({ ...record, reply: replies, turnFinished: true, ...(failed ? { turnFailed: true } : {}) });
58
59
  this.update(id);
59
60
  }
60
61
 
62
+ /**
63
+ * 转交里派的任务结束了、或用户答了转交里的提问:结果先回到目标助手,它这一轮可能接着再派任务、再问用户。
64
+ * 这一轮跑完、名下没有在跑的任务,转交才算结束,Hob 才收到结果;
65
+ * 否则目标助手还在处理时 Hob 就会收到「已结束」,另开任务和它冲突。
66
+ * 调用方要在发出 job/approval 事件之前调用,再用 router.relay 把结果送给目标助手。
67
+ * 返回 false 表示转交已经回流过(或已退回),结果按普通事件处理。
68
+ */
69
+ resume(id: string): boolean {
70
+ const record = this.runtime.store.delegation(id);
71
+ if (!record || record.reportedAt || record.status === "returned" || record.status === "interrupted") return false;
72
+ const { turnFailed: _failed, ...rest } = record;
73
+ this.runtime.store.saveDelegation({ ...rest, turnFinished: false });
74
+ return true;
75
+ }
76
+
61
77
  returnToHob(id: string, text: string, reason: string): void {
62
78
  const record = this.runtime.store.delegation(id);
63
79
  if (!record || record.turnFinished) throw new Error("转交已结束,无法退回");
@@ -102,6 +118,8 @@ export class DelegationManager {
102
118
  for (const record of this.runtime.store.delegations()) {
103
119
  if (record.reportedAt || !["running", "waiting", "returned"].includes(record.status)) continue;
104
120
  if (record.jobIds.some((id) => { const job = this.runtime.store.job(id); return job && ["running", "queued", "waiting"].includes(job.status); })) continue;
121
+ // 目标助手在等用户选:审批卡重启后还在,答了照常继续,不算中断
122
+ if (record.approvalIds?.some((id) => this.runtime.store.approval(id)?.status === "pending")) continue;
105
123
  const now = new Date().toISOString();
106
124
  const next: Delegation = { ...record, status: "interrupted", endedAt: now, reportedAt: now };
107
125
  this.runtime.store.saveDelegation(next);
@@ -26,6 +26,7 @@ import { answerApproval, createFromDraft, dismissDraft, mergeAssistant } from ".
26
26
  import { setPassword } from "./config.ts";
27
27
  import { newId } from "./ids.ts";
28
28
  import { listJobFiles, lookupFile, MAX_TEXT_BYTES } from "./files.ts";
29
+ import { thumbnail } from "./thumbs.ts";
29
30
  import { localLoreHome, readLocalLore, readLocalLoreConnection } from "./imports.ts";
30
31
  import { checkImageSettings, generateImage, imageConfigured, imageKey, IMAGE_KEY_SECRET } from "./image.ts";
31
32
  import { checkAssistantModels, checkModelsPatch } from "./models.ts";
@@ -494,7 +495,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
494
495
  files?: string[];
495
496
  };
496
497
  const text = (body.text ?? "").trim();
497
- if (!text) return c.json({ error: "消息内容为空" }, 400);
498
+ // 和 Telegram 一样,只发图片 / 文件不写字也行
499
+ if (!text && !body.files?.length) return c.json({ error: "消息内容为空" }, 400);
498
500
  const item = await router.send(id, {
499
501
  text,
500
502
  ...(body.quote ? { quote: body.quote } : {}),
@@ -709,7 +711,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
709
711
  return c.json({ files: saved.map((item) => item.path), file: saved[0] });
710
712
  });
711
713
 
712
- app.get("/api/files", (c) => {
714
+ app.get("/api/files", async (c) => {
713
715
  const requested = c.req.query("path");
714
716
  const lookup = requested ? lookupFile(options.home, requested) : null;
715
717
  const nosniff = { "x-content-type-options": "nosniff" };
@@ -728,6 +730,11 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
728
730
  const name = encodeURIComponent(info.name);
729
731
  headers["content-disposition"] = `${c.req.query("inline") === "1" ? "inline" : "attachment"}; filename*=UTF-8''${name}`;
730
732
  }
733
+ // 对话里的图片只要缩略图(?thumb=1);缩不了 / 不值得缩就给原图
734
+ if (c.req.query("thumb") === "1" && info.kind === "image") {
735
+ const thumb = await thumbnail(options.home, info);
736
+ if (thumb) return c.body(new Uint8Array(thumb.data), 200, { ...nosniff, "content-type": thumb.mime, "cache-control": "private, max-age=604800" });
737
+ }
731
738
  // 文本只拉 1 MB:预览面板对超大文件只显示前 1 MB,剩下的走下载
732
739
  const whole = readFileSync(info.path);
733
740
  const body = info.text && c.req.query("truncate") === "1" ? whole.subarray(0, MAX_TEXT_BYTES) : whole;
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, extname } from "node:path";
4
4
  import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
5
  import type { ImageContent } from "@earendil-works/pi-ai";
@@ -50,13 +50,17 @@ export function hasImageFiles(files: string[] | undefined): boolean {
50
50
  return (files ?? []).some((file) => isImagePath(file));
51
51
  }
52
52
 
53
- /** 附件里的图片读成 pi 的图片块(读不出来就跳过,不把整个任务拖死)。 */
54
- function imageContents(files: string[] | undefined): ImageContent[] {
53
+ /** 一张图超过这个大小就不当图片给模型(只留路径),免得把会话撑得太大。 */
54
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
55
+
56
+ /** 附件里的图片读成 pi 的图片块(读不出来、太大就跳过,不把整个回合拖死)。任务和对话共用。 */
57
+ export function imageContents(files: string[] | undefined): ImageContent[] {
55
58
  const out: ImageContent[] = [];
56
59
  for (const file of files ?? []) {
57
60
  const mimeType = IMAGE_TYPES[extname(file).toLowerCase()];
58
61
  if (!mimeType) continue;
59
62
  try {
63
+ if (statSync(file).size > MAX_IMAGE_BYTES) continue;
60
64
  out.push({ type: "image", data: readFileSync(file).toString("base64"), mimeType });
61
65
  } catch {
62
66
  // 文件没了或读不了:不带图片,靠任务说明的路径自己去读
@@ -91,6 +95,8 @@ interface WorkerState {
91
95
  stopped?: boolean;
92
96
  /** 正在跑的工具步骤,按 toolCallId 存:输出更新、跑完都按同一个步骤 id 替换 */
93
97
  steps: Map<string, JobStep>;
98
+ /** 你补的话已交给任务、还没被读到(任务做完当前这一步才读);读到时换成正式的一行 */
99
+ queued: JobStep[];
94
100
  }
95
101
 
96
102
  /** 推给助手的任务结果最多带这么多字的报告。 */
@@ -166,10 +172,11 @@ export class JobManager {
166
172
  transcript(jobId: string): JobStep[] {
167
173
  const job = this.store.job(jobId);
168
174
  if (!job) return [];
169
- return deriveSteps(this.store.workerFile(jobId), {
175
+ const steps = deriveSteps(this.store.workerFile(jobId), {
170
176
  cwd: job.cwd ?? "",
171
177
  save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
172
178
  });
179
+ return [...steps, ...(this.workers.get(jobId)?.queued ?? [])];
173
180
  }
174
181
 
175
182
  async start(assistantId: string, input: StartJobInput): Promise<Job> {
@@ -297,6 +304,7 @@ export class JobManager {
297
304
  askedFollowUp: false,
298
305
  lastActivityAt: Date.now(),
299
306
  steps: new Map(),
307
+ queued: [],
300
308
  };
301
309
  this.workers.set(running.id, worker);
302
310
  const images = imageContents(running.inputs);
@@ -345,6 +353,15 @@ export class JobManager {
345
353
  }
346
354
  if (event.type === "message_start" || event.type === "agent_start") {
347
355
  worker.lastActivityAt = Date.now();
356
+ if (event.type === "message_start" && event.message.role === "user") {
357
+ // 补的那句被任务读到了:id 和 transcript 推导出来的一样,前端拿它换掉「等待读取」那行
358
+ const text = textOfContent(event.message.content).trim();
359
+ const index = worker.queued.findIndex((step) => step.text === text);
360
+ if (index >= 0) {
361
+ worker.queued.splice(index, 1);
362
+ this.emitStep(worker, { id: `u${event.message.timestamp}`, kind: "text", at: new Date(event.message.timestamp).toISOString(), from: "user", text });
363
+ }
364
+ }
348
365
  return;
349
366
  }
350
367
  if (event.type === "agent_settled") void this.onSettled(worker);
@@ -535,6 +552,8 @@ export class JobManager {
535
552
  ...(options.files?.length ? { files: options.files } : {}),
536
553
  };
537
554
  this.store.saveJob(next);
555
+ // 转交里的任务:先把转交重新打开,结果由目标助手接着处理完才回流 Hob(见 DelegationManager.resume)
556
+ const delegationId = !job.routineId && job.delegationId && this.runtime.delegations.resume(job.delegationId) ? job.delegationId : undefined;
538
557
  this.bus.emit({ type: "job", job: next });
539
558
  pushJobEnd({ push: this.runtime.push, store: this.store }, next);
540
559
  this.publishBusy(job.assistantId);
@@ -565,13 +584,9 @@ export class JobManager {
565
584
  .catch(() => undefined);
566
585
  }
567
586
  } else if (assistant) {
568
- await this.runtime.router
569
- .event(
570
- job.assistantId,
571
- `任务「${job.title}」${statusText}${eventSummary ? `:${eventSummary}` : options.reason ? `:${options.reason}` : ""}${filesNote}`,
572
- cont,
573
- )
574
- .catch(() => undefined);
587
+ const text = `任务「${job.title}」${statusText}${eventSummary ? `:${eventSummary}` : options.reason ? `:${options.reason}` : ""}${filesNote}`;
588
+ if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId, cont).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
589
+ else await this.runtime.router.event(job.assistantId, text, cont).catch(() => undefined);
575
590
  }
576
591
  }
577
592
 
@@ -605,8 +620,17 @@ export class JobManager {
605
620
  async reply(jobId: string, text: string): Promise<void> {
606
621
  const worker = this.workers.get(jobId);
607
622
  if (!worker?.session) throw new Error("这个任务当前未在运行。");
608
- if (worker.session.isStreaming) await worker.session.steer(text);
609
- else await worker.session.prompt(text);
623
+ // 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
624
+ const step: JobStep = { id: `q${Date.now()}-${worker.queued.length}`, kind: "text", at: new Date().toISOString(), from: "user", text, queued: true };
625
+ worker.queued.push(step);
626
+ this.emitStep(worker, step);
627
+ try {
628
+ if (worker.session.isStreaming) await worker.session.steer(text);
629
+ else void worker.session.prompt(text).catch(() => { worker.queued = worker.queued.filter((item) => item !== step); });
630
+ } catch (error) {
631
+ worker.queued = worker.queued.filter((item) => item !== step);
632
+ throw error;
633
+ }
610
634
  }
611
635
 
612
636
  async stop(jobId: string): Promise<void> {
@@ -670,23 +694,31 @@ export class JobManager {
670
694
  if (worker) worker.job = next;
671
695
  }
672
696
  worker?.ask?.(answer);
673
- if (worker) worker.ask = undefined;
697
+ if (worker) {
698
+ worker.ask = undefined;
699
+ worker.lastActivityAt = Date.now();
700
+ }
674
701
  }
675
702
 
676
703
  startWatchdog(): void {
677
704
  this.watchdog ??= setInterval(() => {
678
- const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
679
- for (const [jobId, worker] of this.workers) {
680
- if (Date.now() - worker.lastActivityAt > idleMs) {
681
- void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
682
- }
683
- }
705
+ this.checkStalls();
684
706
  // 并发小了以后(设置里改的),排队的任务还在等,这里补一次泵
685
707
  this.pump();
686
708
  }, 30_000);
687
709
  this.watchdog.unref?.();
688
710
  }
689
711
 
712
+ checkStalls(now = Date.now()): void {
713
+ const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
714
+ for (const [jobId, worker] of this.workers) {
715
+ // 在等用户答提问不算没响应:等多久都是用户的事
716
+ if (!worker.ask && now - worker.lastActivityAt > idleMs) {
717
+ void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
718
+ }
719
+ }
720
+ }
721
+
690
722
  stopWatchdog(): void {
691
723
  if (this.watchdog) clearInterval(this.watchdog);
692
724
  this.watchdog = undefined;
@@ -169,6 +169,8 @@ export function buildSystemPrompt(store: Store, assistantId: string, settings: P
169
169
  .join(",")};hob 那个火苗是主助手专用的,不要用。`,
170
170
  "- 库里没有贴合职责的道具时,可以自己画一件:sprite 填 custom,prop_art 给出 art、pal、dx 画一件 16×16 的像素道具;画得不合适会返回原因,照着改好重发就行。",
171
171
  "- 有更合适的助手时用 message_assistant 把任务转过去。",
172
+ "- 转交时把用户原话转过去,只补充对方需要的背景。不要替用户加验收标准、测试方案或额外要求,把小事写成大工程。",
173
+ "- 转交出去的事由对方负责到底。结果回来之前,不要自己 start_job 做同一件事。结果回来后如果没办完,转回同一个助手接着办,或者如实告诉用户。",
172
174
  );
173
175
  }
174
176
  lines.push("- 用户问你在做什么、有什么在运行,可依据任务事件回答,不要编造进度。");
@@ -1,4 +1,5 @@
1
1
  // HOB_HOME/push.json:VAPID 密钥 + 订阅设备(0600、原子写)。推送走 web-push。
2
+ import { plainText } from "../../shared/plain-text.ts";
2
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import { dirname, join } from "node:path";
@@ -269,7 +270,7 @@ export function pushApproval(deps: NotifyDeps, approval: Approval): void {
269
270
  const assistant = deps.store.assistant(approval.assistantId);
270
271
  if (!assistant?.notify) return;
271
272
  // 推送失败不能影响审批本身(也不能变成未处理的 rejection 把进程带走)
272
- void deps.push.send({ title: assistant.name, body: approval.title, url: openUrl(approval.assistantId) }).catch(() => undefined);
273
+ void deps.push.send({ title: assistant.name, body: plainText(approval.title), url: openUrl(approval.assistantId) }).catch(() => undefined);
273
274
  }
274
275
 
275
276
  /** 任务受阻或失败:所属助手开了通知才推送。 */
@@ -278,7 +279,7 @@ export function pushJobEnd(deps: NotifyDeps, job: Job): void {
278
279
  const assistant = deps.store.assistant(job.assistantId);
279
280
  if (!assistant?.notify) return;
280
281
  const what = job.status === "stuck" ? "受阻" : "失败";
281
- const reason = job.statusReason ? `:${job.statusReason}` : "";
282
+ const reason = job.statusReason ? `:${plainText(job.statusReason)}` : "";
282
283
  void deps.push
283
284
  .send({ title: assistant.name, body: `任务「${job.title}」${what}${reason}`, url: openUrl(job.assistantId) })
284
285
  .catch(() => undefined);
@@ -0,0 +1,47 @@
1
+ import { basename } from "node:path";
2
+
3
+ /** 把 Router 工具和流式参数压成界面上的一句话。 */
4
+ export function activityLabel(name: string, args: Record<string, unknown> = {}, assistantName?: (id: string) => string | undefined): string {
5
+ const value = (key: string) => typeof args[key] === "string" ? (args[key] as string).trim() : "";
6
+ const withValue = (label: string, text: string) => text ? `${label}:${text}` : label;
7
+ let label: string;
8
+ switch (name) {
9
+ case "start_job": label = withValue("派任务", value("title")); break;
10
+ case "reply_job": label = "补充任务要求"; break;
11
+ case "stop_job": label = "停止任务"; break;
12
+ case "ask_user": label = "准备问你"; break;
13
+ case "set_rule": case "remove_rule": label = "改规矩"; break;
14
+ case "create_routine": case "update_routine": case "delete_routine": label = withValue("设定时任务", value("name")); break;
15
+ case "send_file": label = withValue("发文件", basename(value("path"))); break;
16
+ case "set_avatar": label = "换头像"; break;
17
+ case "propose_assistant": label = "提议新助手"; break;
18
+ case "message_assistant": { const id = value("assistantId"); label = id ? `交给「${assistantName?.(id) ?? id}」` : "交给助手"; break; }
19
+ case "hand_back_to_hob": label = "交回 Hob"; break;
20
+ case "show_page": label = withValue("写页面", value("title")); break;
21
+ case "web_search": label = withValue("搜索", value("query")); break;
22
+ case "web_fetch": label = "读取网页"; break;
23
+ case "generate_image": label = "画图"; break;
24
+ case "overview": label = "查看工作概况"; break;
25
+ case "read_skill": label = withValue("看技能", value("name")); break;
26
+ default:
27
+ label = /^(memory_write|memory_forget|memory_restore|lore_create_node|lore_update_node|lore_delete_node|lore_move_node)$/.test(name) ? "记下来"
28
+ : /^(memory_|lore_|scratchpad)/.test(name) ? "翻记忆" : "调用工具";
29
+ }
30
+ return [...label].length > 24 ? `${[...label].slice(0, 23).join("")}…` : label;
31
+ }
32
+
33
+ /**
34
+ * 思考中那一行:模型当前思考的最后一段压成一行(照 Pix 的 Codex 模式,只在想的时候显示,不进对话记录)。
35
+ * 最后一段太短(如「写吧。」)就带上前一段;太长就留最新的尾巴,前面用 … 代替。
36
+ */
37
+ export function thinkingLine(thinking: string): string | undefined {
38
+ const parts = thinking
39
+ .split(/\n+/)
40
+ .map((part) => part.replace(/^\s*(?:[-*#>]+|\d+[.)])\s*/, "").replace(/\*\*|__|`/g, "").replace(/\s+/g, " ").trim())
41
+ .filter(Boolean);
42
+ let line = parts.pop();
43
+ if (!line) return undefined;
44
+ while ([...line].length < 12 && parts.length) line = `${parts.pop()} ${line}`;
45
+ const chars = [...line];
46
+ return chars.length > 110 ? `…${chars.slice(-109).join("")}` : line;
47
+ }
@@ -15,6 +15,7 @@ import type {
15
15
  PageLive,
16
16
  Profile,
17
17
  SendMessageBody,
18
+ RouterActivity,
18
19
  } from "../../shared/types.ts";
19
20
  import type { HobRuntime } from "./runtime.ts";
20
21
  import { refreshSystemPrompt } from "./pi.ts";
@@ -23,6 +24,8 @@ import { buildRouterTools } from "./router-tools.ts";
23
24
  import { MEMORY_WRITE_TOOLS, memoryNoteText } from "./memory.ts";
24
25
  import { buildSystemPrompt, type PromptModel } from "./prompt.ts";
25
26
  import { availableSkills } from "./skills.ts";
27
+ import { activityLabel, thinkingLine } from "./router-activity.ts";
28
+ import { imageContents } from "./jobs.ts";
26
29
 
27
30
  export const ITEM_TYPE = "hob.item";
28
31
  export const USER_TYPE = "hob.user";
@@ -46,6 +49,11 @@ interface RouterState {
46
49
  idleTimer?: NodeJS.Timeout;
47
50
  /** 还有几轮在跑(事件那一轮也要算),用来判断忙闲和等空闲 */
48
51
  running: number;
52
+ activity?: RouterActivity | null;
53
+ activityJson?: string;
54
+ activityLast?: number;
55
+ /** 这次思考到目前为止的原文:只用来取最后一段给状态行 */
56
+ thinkingText?: string;
49
57
  /** 正在流式的助手消息:条目 id 和 GET /items 推导出来的一致;shown 表示已经在对话里露过面 */
50
58
  streaming?: { itemId: string; shown: boolean };
51
59
  /** 这一轮最后一次有动静的时间,看门狗用它判断“没响应” */
@@ -306,9 +314,22 @@ export class RouterManager {
306
314
  */
307
315
  private onSessionEvent(state: RouterState, assistantId: string, event: AgentSessionEvent): void {
308
316
  this.progress(state);
317
+ if (event.type === "agent_start" || (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_start")) {
318
+ state.thinkingText = "";
319
+ this.setActivity(state, { state: "thinking", label: "正在想…" });
320
+ }
321
+ if (event.type === "message_update" && (event.assistantMessageEvent.type === "thinking_delta" || event.assistantMessageEvent.type === "thinking_end")) {
322
+ if (event.assistantMessageEvent.type === "thinking_delta") state.thinkingText = (state.thinkingText ?? "") + event.assistantMessageEvent.delta;
323
+ // 节流;思考结束时补发一次完整的最后一段
324
+ if (event.assistantMessageEvent.type === "thinking_end" || Date.now() - (state.activityLast ?? 0) >= 200) {
325
+ this.setActivity(state, { state: "thinking", label: thinkingLine(state.thinkingText ?? "") ?? "正在想…" });
326
+ state.activityLast = Date.now();
327
+ }
328
+ }
329
+ if (event.type === "agent_end" || (event.type === "message_end" && event.message.role === "assistant" && ["error", "aborted"].includes(event.message.stopReason))) this.setActivity(state, null);
309
330
  if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
310
331
  const details = event.message.details as EventDetails | undefined;
311
- if (details?.delegationId) {
332
+ if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
312
333
  state.currentDelegationId = details.delegationId;
313
334
  state.delegationReply = [];
314
335
  state.terminatingTools = new Set();
@@ -320,7 +341,7 @@ export class RouterManager {
320
341
  const failed = event.message.stopReason === "error" || event.message.stopReason === "aborted";
321
342
  if (failed || !event.toolResults.length || event.toolResults.every((tool) => state.terminatingTools?.has(tool.toolCallId))) {
322
343
  const id = state.currentDelegationId;
323
- const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "(没有回复)");
344
+ const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "");
324
345
  state.currentDelegationId = undefined;
325
346
  state.delegationReply = undefined;
326
347
  state.terminatingTools = undefined;
@@ -349,6 +370,15 @@ export class RouterManager {
349
370
  if (event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_start" || event.assistantMessageEvent.type === "toolcall_delta" || event.assistantMessageEvent.type === "toolcall_end")) {
350
371
  const update = event.assistantMessageEvent;
351
372
  const part = update.partial.content[update.contentIndex];
373
+ if (part?.type === "toolCall") {
374
+ if (update.type === "toolcall_start") state.activityJson = "";
375
+ if (update.type === "toolcall_delta") state.activityJson = (state.activityJson ?? "") + update.delta;
376
+ if (update.type !== "toolcall_delta" || Date.now() - (state.activityLast ?? 0) >= 200) {
377
+ const args = Object.keys(part.arguments).length ? part.arguments : parseStreamingJson(state.activityJson);
378
+ this.setActivity(state, { state: "tool", label: activityLabel(part.name, args, (id) => this.store.assistant(id)?.name) });
379
+ state.activityLast = Date.now();
380
+ }
381
+ }
352
382
  if (part?.type === "toolCall" && part.name === "show_page") {
353
383
  if (update.type === "toolcall_start") this.liveJson.set(part.id, "");
354
384
  if (update.type === "toolcall_delta") this.liveJson.set(part.id, (this.liveJson.get(part.id) ?? "") + update.delta);
@@ -361,6 +391,7 @@ export class RouterManager {
361
391
  for (const [id, live] of this.livePages.get(assistantId) ?? []) if (live.page.state === "writing") this.liveFinish(assistantId, id, undefined, reason || "页面生成中断");
362
392
  }
363
393
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
394
+ this.setActivity(state, null);
364
395
  state.streaming ??= { itemId: `a-${this.assistantCount(assistantId) + 1}`, shown: false };
365
396
  const streaming = state.streaming;
366
397
  if (!streaming.shown) {
@@ -417,9 +448,16 @@ export class RouterManager {
417
448
  ).length;
418
449
  }
419
450
 
451
+ private setActivity(state: RouterState, activity: RouterActivity | null): void {
452
+ if (state.activity?.state === activity?.state && state.activity?.label === activity?.label) return;
453
+ state.activity = activity;
454
+ this.bus.emit({ type: "router-activity", assistantId: state.assistantId, activity });
455
+ }
456
+
420
457
  /** 一轮开始了:清掉上一轮的“已停”标记并起看门狗。 */
421
458
  private armStall(state: RouterState): void {
422
459
  state.stalled = false;
460
+ if (state.running === 1 && !state.session?.isStreaming) this.setActivity(state, { state: "thinking", label: "正在想…" });
423
461
  this.progress(state);
424
462
  }
425
463
 
@@ -449,6 +487,7 @@ export class RouterManager {
449
487
  }
450
488
  if (state.running === 0 && !state.session?.isStreaming) return;
451
489
  state.stalled = true;
490
+ this.setActivity(state, null);
452
491
  // 不 await:模型的那次请求可能收不了尾,但这行提示必须先出来
453
492
  void state.session?.abort().catch(() => undefined);
454
493
  const assistant = this.store.assistant(state.assistantId);
@@ -567,6 +606,7 @@ export class RouterManager {
567
606
  return false;
568
607
  } finally {
569
608
  state.running = Math.max(0, state.running - 1);
609
+ if (!state.running) this.setActivity(state, null);
570
610
  this.clearStall(state);
571
611
  this.publishBusy(state);
572
612
  this.maybeRelease(state);
@@ -666,7 +706,7 @@ export class RouterManager {
666
706
  const lines = [body.text];
667
707
  if (body.quote) lines.unshift(`(引用我之前的话:「${body.quote.text}」)`);
668
708
  if (body.files?.length) lines.push(`附件:${body.files.join("、")}`);
669
- const promptText = lines.join("\n");
709
+ const promptText = lines.filter(Boolean).join("\n");
670
710
 
671
711
  this.touch(assistantId);
672
712
  state.running += 1;
@@ -676,15 +716,18 @@ export class RouterManager {
676
716
  try {
677
717
  await this.applyConversationModel(state);
678
718
  refreshSystemPrompt(state.session!);
679
- if (state.session!.isStreaming) await state.session!.steer(promptText);
719
+ // 对话模型能看图就把图片直接给它(路径照样写在「附件」里,派任务时还能交出去);看不了图就只有路径
720
+ const images = state.session!.model?.input.includes("image") ? imageContents(body.files) : [];
721
+ if (state.session!.isStreaming) await state.session!.steer(promptText, images.length ? images : undefined);
680
722
  else {
681
- await state.session!.prompt(promptText);
723
+ await state.session!.prompt(promptText, images.length ? { images } : undefined);
682
724
  await this.reportTurnError(state);
683
725
  }
684
726
  } catch (error) {
685
727
  await this.fail(state, error);
686
728
  } finally {
687
729
  state.running = Math.max(0, state.running - 1);
730
+ if (!state.running) this.setActivity(state, null);
688
731
  this.clearStall(state);
689
732
  this.publishBusy(state);
690
733
  this.maybeRelease(state);
@@ -696,6 +739,7 @@ export class RouterManager {
696
739
 
697
740
  async stop(assistantId: string): Promise<void> {
698
741
  const state = this.routers.get(assistantId);
742
+ if (state) this.setActivity(state, null);
699
743
  if (state?.session) await state.session.abort();
700
744
  }
701
745
 
@@ -790,6 +834,7 @@ export class RouterManager {
790
834
  ...(last ? { last } : {}),
791
835
  unread: this.unread(assistant.id),
792
836
  busy: this.isBusy(assistant.id),
837
+ activity: this.routers.get(assistant.id)?.activity ?? null,
793
838
  };
794
839
  }
795
840
 
@@ -827,8 +872,9 @@ export class RouterManager {
827
872
  }
828
873
 
829
874
  /** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
830
- async relay(targetAssistantId: string, text: string, delegationId: string): Promise<void> {
831
- const success = await this.pushEvent(targetAssistantId, `转自 Hob:${text}`, { delegationId });
875
+ /** 把事件当作转交的一轮送给目标助手;这一轮没正常收尾就把转交记为受阻,不会一直挂着。 */
876
+ async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails): Promise<void> {
877
+ const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId });
832
878
  if (!success || !this.store.delegation(delegationId)?.turnFinished) {
833
879
  this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败或转交被中断", true);
834
880
  }
@@ -84,7 +84,7 @@ export function systemTimezone(): string {
84
84
 
85
85
  export function defaultSettings(timezone = systemTimezone(), models: ModelSettings = seedModels()): Settings {
86
86
  return {
87
- general: { theme: "system", timezone, showPage: true, aboutYou: { name: "", language: "", notes: "" } },
87
+ general: { theme: "system", timezone, showPage: true, avatarBackground: true, aboutYou: { name: "", language: "", notes: "" } },
88
88
  providers: [],
89
89
  models,
90
90
  image: { source: "off" },
@@ -201,6 +201,7 @@ function normalize(raw: unknown, base: Settings): Settings {
201
201
  theme: THEMES.includes(theme) ? theme : base.general.theme,
202
202
  timezone: checkTimezone(text(general.timezone, "") || base.general.timezone),
203
203
  showPage: typeof general.showPage === "boolean" ? general.showPage : base.general.showPage !== false,
204
+ avatarBackground: typeof general.avatarBackground === "boolean" ? general.avatarBackground : base.general.avatarBackground !== false,
204
205
  aboutYou: {
205
206
  name: text(about.name, base.general.aboutYou.name),
206
207
  language: text(about.language, base.general.aboutYou.language),