hob-bot 1.0.3 → 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.
- package/dist/web/assets/index-BCGEZvgg.css +1 -0
- package/dist/web/assets/index-BqkIAlk9.js +258 -0
- package/dist/web/index.html +2 -2
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- 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
- package/package.json +4 -2
- package/server/src/actions.ts +8 -1
- package/server/src/delegations.ts +20 -2
- package/server/src/http.ts +9 -2
- package/server/src/jobs.ts +52 -20
- package/server/src/prompt.ts +2 -0
- package/server/src/push.ts +3 -2
- package/server/src/router.ts +11 -7
- package/server/src/thumbs.ts +49 -0
- package/shared/media.ts +6 -0
- package/shared/plain-text.ts +31 -0
- package/shared/sprites.ts +198 -0
- package/shared/types.ts +2 -0
- package/dist/web/assets/index-C72gAwgN.css +0 -1
- package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
27
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
@@ -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.
|
|
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,9 +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
42
|
"katex": "^0.19.1",
|
|
42
|
-
"marked": "^
|
|
43
|
+
"marked": "^18.1.3",
|
|
44
|
+
"marked-cjk-friendly": "^0.1.3",
|
|
43
45
|
"pi-memory": "0.4.2",
|
|
44
46
|
"react": "^19.0.0",
|
|
45
47
|
"react-dom": "^19.0.0",
|
package/server/src/actions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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);
|
package/server/src/http.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/server/src/jobs.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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)
|
|
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
|
-
|
|
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;
|
package/server/src/prompt.ts
CHANGED
|
@@ -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("- 用户问你在做什么、有什么在运行,可依据任务事件回答,不要编造进度。");
|
package/server/src/push.ts
CHANGED
|
@@ -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);
|
package/server/src/router.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { MEMORY_WRITE_TOOLS, memoryNoteText } from "./memory.ts";
|
|
|
25
25
|
import { buildSystemPrompt, type PromptModel } from "./prompt.ts";
|
|
26
26
|
import { availableSkills } from "./skills.ts";
|
|
27
27
|
import { activityLabel, thinkingLine } from "./router-activity.ts";
|
|
28
|
+
import { imageContents } from "./jobs.ts";
|
|
28
29
|
|
|
29
30
|
export const ITEM_TYPE = "hob.item";
|
|
30
31
|
export const USER_TYPE = "hob.user";
|
|
@@ -328,7 +329,7 @@ export class RouterManager {
|
|
|
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);
|
|
329
330
|
if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
|
|
330
331
|
const details = event.message.details as EventDetails | undefined;
|
|
331
|
-
if (details?.delegationId) {
|
|
332
|
+
if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
|
|
332
333
|
state.currentDelegationId = details.delegationId;
|
|
333
334
|
state.delegationReply = [];
|
|
334
335
|
state.terminatingTools = new Set();
|
|
@@ -340,7 +341,7 @@ export class RouterManager {
|
|
|
340
341
|
const failed = event.message.stopReason === "error" || event.message.stopReason === "aborted";
|
|
341
342
|
if (failed || !event.toolResults.length || event.toolResults.every((tool) => state.terminatingTools?.has(tool.toolCallId))) {
|
|
342
343
|
const id = state.currentDelegationId;
|
|
343
|
-
const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "
|
|
344
|
+
const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "");
|
|
344
345
|
state.currentDelegationId = undefined;
|
|
345
346
|
state.delegationReply = undefined;
|
|
346
347
|
state.terminatingTools = undefined;
|
|
@@ -705,7 +706,7 @@ export class RouterManager {
|
|
|
705
706
|
const lines = [body.text];
|
|
706
707
|
if (body.quote) lines.unshift(`(引用我之前的话:「${body.quote.text}」)`);
|
|
707
708
|
if (body.files?.length) lines.push(`附件:${body.files.join("、")}`);
|
|
708
|
-
const promptText = lines.join("\n");
|
|
709
|
+
const promptText = lines.filter(Boolean).join("\n");
|
|
709
710
|
|
|
710
711
|
this.touch(assistantId);
|
|
711
712
|
state.running += 1;
|
|
@@ -715,9 +716,11 @@ export class RouterManager {
|
|
|
715
716
|
try {
|
|
716
717
|
await this.applyConversationModel(state);
|
|
717
718
|
refreshSystemPrompt(state.session!);
|
|
718
|
-
|
|
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);
|
|
719
722
|
else {
|
|
720
|
-
await state.session!.prompt(promptText);
|
|
723
|
+
await state.session!.prompt(promptText, images.length ? { images } : undefined);
|
|
721
724
|
await this.reportTurnError(state);
|
|
722
725
|
}
|
|
723
726
|
} catch (error) {
|
|
@@ -869,8 +872,9 @@ export class RouterManager {
|
|
|
869
872
|
}
|
|
870
873
|
|
|
871
874
|
/** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
|
|
872
|
-
|
|
873
|
-
|
|
875
|
+
/** 把事件当作转交的一轮送给目标助手;这一轮没正常收尾就把转交记为受阻,不会一直挂着。 */
|
|
876
|
+
async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails): Promise<void> {
|
|
877
|
+
const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId });
|
|
874
878
|
if (!success || !this.store.delegation(delegationId)?.turnFinished) {
|
|
875
879
|
this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败或转交被中断", true);
|
|
876
880
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 对话里的图片缩略图:长边 720px 的 JPEG/PNG,按(路径、大小、修改时间)缓存在 HOB_HOME/cache/thumbs。
|
|
2
|
+
// 对话列表只拉缩略图,点开预览才拉原图。原图本来就小、GIF(可能是动图)、缩不了的,都直接给原图。
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resizeImage } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const THUMB_EDGE = 720;
|
|
9
|
+
const RESIZABLE = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
10
|
+
const inflight = new Map<string, Promise<Thumb | null>>();
|
|
11
|
+
|
|
12
|
+
export interface Thumb {
|
|
13
|
+
data: Uint8Array;
|
|
14
|
+
mime: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function thumbnail(home: string, file: { path: string; size: number; modifiedAt: string; mime: string }): Promise<Thumb | null> {
|
|
18
|
+
if (!RESIZABLE.has(file.mime)) return null;
|
|
19
|
+
const key = createHash("sha1").update(`${file.path}\0${file.size}\0${file.modifiedAt}`).digest("hex");
|
|
20
|
+
const dir = join(home, "cache", "thumbs");
|
|
21
|
+
for (const [ext, mime] of [["jpg", "image/jpeg"], ["png", "image/png"]] as const) {
|
|
22
|
+
const cached = join(dir, `${key}.${ext}`);
|
|
23
|
+
if (existsSync(cached)) return { data: readFileSync(cached), mime };
|
|
24
|
+
}
|
|
25
|
+
// 原图大小就不值得缩:记一个空标记,下次直接给原图
|
|
26
|
+
if (existsSync(join(dir, `${key}.orig`))) return null;
|
|
27
|
+
let job = inflight.get(key);
|
|
28
|
+
if (!job) {
|
|
29
|
+
job = make(dir, key, file).finally(() => inflight.delete(key));
|
|
30
|
+
inflight.set(key, job);
|
|
31
|
+
}
|
|
32
|
+
return job;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function make(dir: string, key: string, file: { path: string; mime: string }): Promise<Thumb | null> {
|
|
36
|
+
const resized = await resizeImage(readFileSync(file.path), file.mime, { maxWidth: THUMB_EDGE, maxHeight: THUMB_EDGE, maxBytes: 400_000, jpegQuality: 82 }).catch(() => null);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
if (!resized || !resized.wasResized) {
|
|
39
|
+
writeFileSync(join(dir, `${key}.orig`), "");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const data = Buffer.from(resized.data, "base64");
|
|
43
|
+
const target = join(dir, `${key}.${resized.mimeType === "image/png" ? "png" : "jpg"}`);
|
|
44
|
+
// 先写临时文件再改名:并发读到的不会是半截图
|
|
45
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(tmp, data);
|
|
47
|
+
renameSync(tmp, target);
|
|
48
|
+
return { data, mime: resized.mimeType };
|
|
49
|
+
}
|
package/shared/media.ts
CHANGED
|
@@ -15,3 +15,9 @@ export function isImagePath(path: string): boolean {
|
|
|
15
15
|
if (dot === -1) return false;
|
|
16
16
|
return path.slice(dot).toLowerCase() in IMAGE_TYPES;
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/** 上传的附件存成 u-xxxxxx-原名(server 的 /api/uploads),给人看时去掉前面的编号。 */
|
|
20
|
+
export function attachmentName(path: string): string {
|
|
21
|
+
const base = path.split("/").pop() ?? path;
|
|
22
|
+
return base.replace(/^u-[a-z0-9]{6}-/, "");
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 把 Markdown 收成一行纯文字:首页列表的预览、引用条、推送通知都用它,不显示 ** # ` 这些记号。
|
|
2
|
+
// 只去记号、留内容;蛇形命名里的下划线(snake_case)这种不在词边上的不动。
|
|
3
|
+
|
|
4
|
+
export function plainText(md: string): string {
|
|
5
|
+
return (
|
|
6
|
+
md
|
|
7
|
+
// 代码块:留代码,去围栏和语言名
|
|
8
|
+
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
|
|
9
|
+
// 图片留说明文字,链接留链接文字
|
|
10
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
11
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
12
|
+
// 行首:标题、引用、列表记号、任务框
|
|
13
|
+
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
|
|
14
|
+
.replace(/^[ \t]*>[ \t]?/gm, "")
|
|
15
|
+
.replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, "")
|
|
16
|
+
// 表格:分隔行整行去掉,竖线换成空格
|
|
17
|
+
.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, "")
|
|
18
|
+
.replace(/[ \t]*\|[ \t]*/g, " ")
|
|
19
|
+
// 分隔线
|
|
20
|
+
.replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gm, "")
|
|
21
|
+
// 粗体、斜体、删除线、行内代码
|
|
22
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
|
23
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1")
|
|
24
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2")
|
|
25
|
+
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2")
|
|
26
|
+
.replace(/`([^`\n]+)`/g, "$1")
|
|
27
|
+
// 多行收成一行
|
|
28
|
+
.replace(/\s+/g, " ")
|
|
29
|
+
.trim()
|
|
30
|
+
);
|
|
31
|
+
}
|