hob-bot 1.0.4 → 1.2.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.
- package/README.md +76 -6
- package/README.zh-CN.md +76 -6
- package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
- package/dist/web/assets/index-Di5lyTHO.css +1 -0
- package/dist/web/assets/index-y09i1RGd.js +267 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/package.json +7 -1
- package/server/src/actions.ts +16 -56
- package/server/src/assistant-skills.ts +215 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +21 -11
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +325 -13
- package/server/src/job-view.ts +52 -0
- package/server/src/jobs.ts +326 -44
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +4 -4
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +51 -156
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +39 -5
- package/server/src/router-activity.ts +6 -0
- package/server/src/router-tools.ts +184 -39
- package/server/src/router.ts +154 -17
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/skills.ts +18 -0
- package/shared/types.ts +104 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-BCGEZvgg.css +0 -1
- package/dist/web/assets/index-BqkIAlk9.js +0 -258
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-y09i1RGd.js"></script>
|
|
27
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Di5lyTHO.css">
|
|
28
28
|
</head>
|
|
29
29
|
<body>
|
|
30
30
|
<div id="root"></div>
|
|
Binary file
|
package/dist/web/mock/photo.jpg
CHANGED
|
Binary file
|
package/dist/web/mock/photo2.jpg
CHANGED
|
Binary file
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>如果在地球和火星同时看日落</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg: #faf9f6; --fg: #1d1d1f; --mute: #6e6e73; --card: #fff; --line: #e8e6e1; }
|
|
9
|
+
:root.dark { --bg: #161616; --fg: #f2f2f2; --mute: #9a9a9f; --card: #1f1f1f; --line: #2c2c2c; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.6 -apple-system, "PingFang SC", "Noto Sans SC", sans-serif; }
|
|
12
|
+
main { max-width: 760px; margin: 0 auto; padding: 32px 22px 48px; }
|
|
13
|
+
h1 { font-size: 26px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -.01em; }
|
|
14
|
+
.lead { color: var(--mute); margin: 0 0 26px; }
|
|
15
|
+
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
16
|
+
figure { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
|
17
|
+
svg { display: block; width: 100%; height: auto; }
|
|
18
|
+
figcaption { padding: 12px 14px 14px; }
|
|
19
|
+
figcaption b { display: block; font-size: 15px; }
|
|
20
|
+
figcaption span { color: var(--mute); font-size: 13px; }
|
|
21
|
+
h2 { font-size: 17px; margin: 30px 0 10px; }
|
|
22
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
23
|
+
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
|
|
24
|
+
th { color: var(--mute); font-weight: 500; }
|
|
25
|
+
.why { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
26
|
+
.why div { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; }
|
|
27
|
+
.why b { display: block; margin-bottom: 4px; }
|
|
28
|
+
.why p { margin: 0; color: var(--mute); font-size: 13.5px; }
|
|
29
|
+
@media (max-width: 560px) { .pair, .why { grid-template-columns: 1fr; } }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<main>
|
|
34
|
+
<h1>如果在地球和火星同时看日落</h1>
|
|
35
|
+
<p class="lead">同一个视角:太阳刚落到地平线。看太阳周围,再看远离太阳的天空。</p>
|
|
36
|
+
|
|
37
|
+
<div class="pair">
|
|
38
|
+
<figure>
|
|
39
|
+
<svg viewBox="0 0 400 260">
|
|
40
|
+
<defs>
|
|
41
|
+
<linearGradient id="es" x1="0" y1="0" x2="0" y2="1">
|
|
42
|
+
<stop offset="0" stop-color="#3b5d9a"/><stop offset=".45" stop-color="#c9718a"/><stop offset=".78" stop-color="#f29a4a"/><stop offset="1" stop-color="#ffcf73"/>
|
|
43
|
+
</linearGradient>
|
|
44
|
+
<radialGradient id="eg" cx=".5" cy="1" r=".55"><stop offset="0" stop-color="#fff2b8"/><stop offset=".35" stop-color="#ffb35c" stop-opacity=".8"/><stop offset="1" stop-color="#ff8a3d" stop-opacity="0"/></radialGradient>
|
|
45
|
+
</defs>
|
|
46
|
+
<rect width="400" height="260" fill="url(#es)"/>
|
|
47
|
+
<ellipse cx="200" cy="205" rx="230" ry="120" fill="url(#eg)"/>
|
|
48
|
+
<circle cx="200" cy="203" r="17" fill="#fff6d6"/>
|
|
49
|
+
<path d="M0 205 L60 192 L120 200 L190 186 L260 198 L330 188 L400 200 L400 260 L0 260Z" fill="#2b2233"/>
|
|
50
|
+
</svg>
|
|
51
|
+
<figcaption><b>地球</b><span>太阳周围橙红,头顶仍是蓝色</span></figcaption>
|
|
52
|
+
</figure>
|
|
53
|
+
<figure>
|
|
54
|
+
<svg viewBox="0 0 400 260">
|
|
55
|
+
<defs>
|
|
56
|
+
<linearGradient id="ms" x1="0" y1="0" x2="0" y2="1">
|
|
57
|
+
<stop offset="0" stop-color="#5a4334"/><stop offset=".5" stop-color="#a0785a"/><stop offset="1" stop-color="#c79c74"/>
|
|
58
|
+
</linearGradient>
|
|
59
|
+
<radialGradient id="mg" cx=".5" cy="1" r=".42"><stop offset="0" stop-color="#e6f2ff"/><stop offset=".3" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="1" stop-color="#5f86c4" stop-opacity="0"/></radialGradient>
|
|
60
|
+
</defs>
|
|
61
|
+
<rect width="400" height="260" fill="url(#ms)"/>
|
|
62
|
+
<ellipse cx="200" cy="208" rx="170" ry="110" fill="url(#mg)"/>
|
|
63
|
+
<circle cx="200" cy="206" r="11" fill="#f4f8ff"/>
|
|
64
|
+
<path d="M0 208 L70 198 L140 204 L210 194 L280 203 L350 196 L400 202 L400 260 L0 260Z" fill="#4a2c22"/>
|
|
65
|
+
</svg>
|
|
66
|
+
<figcaption><b>火星</b><span>太阳周围泛蓝,远处是黄褐色</span></figcaption>
|
|
67
|
+
</figure>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<h2>为什么正好相反</h2>
|
|
71
|
+
<div class="why">
|
|
72
|
+
<div><b>地球:气体分子</b><p>空气分子更容易把蓝光散射到四面八方。日落时光路变长,蓝光在路上散尽,只剩橙红照过来。</p></div>
|
|
73
|
+
<div><b>火星:尘埃颗粒</b><p>尘埃颗粒约 1.5 µm,把蓝光集中朝前方散射,所以蓝色只聚在太阳附近,其余天空被尘埃染成黄褐。</p></div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<h2>对比</h2>
|
|
77
|
+
<table>
|
|
78
|
+
<tr><th>项目</th><th>地球</th><th>火星</th></tr>
|
|
79
|
+
<tr><td>大气压</td><td>1013 hPa</td><td>约 6 hPa</td></tr>
|
|
80
|
+
<tr><td>主要散射体</td><td>气体分子</td><td>尘埃颗粒</td></tr>
|
|
81
|
+
<tr><td>白天天空</td><td>蓝色</td><td>黄褐色</td></tr>
|
|
82
|
+
<tr><td>日落颜色</td><td>红橙</td><td>蓝色</td></tr>
|
|
83
|
+
</table>
|
|
84
|
+
</main>
|
|
85
|
+
<script>
|
|
86
|
+
const apply = () => document.documentElement.classList.toggle("dark", location.hash === "#dark");
|
|
87
|
+
apply(); addEventListener("hashchange", apply);
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hob-bot",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A self-hosted personal assistant that runs real work as background jobs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,24 +28,30 @@
|
|
|
28
28
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
29
|
"start": "tsx server/src/boot.ts",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
+
"update-lore": "node scripts/update-lore.mjs",
|
|
31
32
|
"prepack": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
|
34
36
|
"@earendil-works/pi-ai": "1.3.0",
|
|
35
37
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
36
38
|
"@hono/node-server": "^2.1.6",
|
|
37
39
|
"@juicesharp/rpiv-config": "2.15.0",
|
|
38
40
|
"@juicesharp/rpiv-web-tools": "2.15.0",
|
|
41
|
+
"@novnc/novnc": "^1.7.0",
|
|
39
42
|
"croner": "^9.0.0",
|
|
40
43
|
"github-markdown-css": "^5.9.0",
|
|
41
44
|
"hono": "^4.6.0",
|
|
42
45
|
"katex": "^0.19.1",
|
|
43
46
|
"marked": "^18.1.3",
|
|
44
47
|
"marked-cjk-friendly": "^0.1.3",
|
|
48
|
+
"pi-claude-bridge": "0.9.3",
|
|
45
49
|
"pi-memory": "0.4.2",
|
|
50
|
+
"playwright-core": "^1.64.1",
|
|
46
51
|
"react": "^19.0.0",
|
|
47
52
|
"react-dom": "^19.0.0",
|
|
48
53
|
"tsx": "^4.19.0",
|
|
54
|
+
"unpdf": "^1.9.1",
|
|
49
55
|
"web-push": "^3.6.7",
|
|
50
56
|
"ws": "^8.18.0"
|
|
51
57
|
},
|
package/server/src/actions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { distinctSprite } from "../../shared/sprites.ts";
|
|
2
|
-
import type { AnswerApprovalBody, Approval, Assistant, Draft
|
|
2
|
+
import type { AnswerApprovalBody, Approval, Assistant, Draft } from "../../shared/types.ts";
|
|
3
3
|
import { newId, slugify } from "./ids.ts";
|
|
4
4
|
import type { HobRuntime } from "./runtime.ts";
|
|
5
5
|
import { monthDayText } from "./time.ts";
|
|
@@ -156,63 +156,18 @@ export function dismissDraft(runtime: HobRuntime, draftId: string): ActionResult
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
159
|
+
* 归档助手:整个目录(档案、对话、规则)移进 archive/,它的定时一并存进去再停掉。
|
|
160
|
+
* 什么都不转给主助手。历史任务留在 jobs/,只补上助手名。
|
|
161
161
|
*/
|
|
162
|
-
export async function
|
|
163
|
-
const { store, bus, router
|
|
162
|
+
export async function archiveAssistant(runtime: HobRuntime, id: string): Promise<ActionResult> {
|
|
163
|
+
const { store, bus, router } = runtime;
|
|
164
164
|
const target = store.assistant(id);
|
|
165
165
|
if (!target) return { status: 404, body: { error: "助手不存在" } };
|
|
166
|
-
if (target.primary) return { status: 400, body: { error: "
|
|
167
|
-
const primary = store.assistants().find((assistant) => assistant.primary);
|
|
168
|
-
if (!primary) return { status: 409, body: { error: "主助手不存在,无法并回" } };
|
|
166
|
+
if (target.primary) return { status: 400, body: { error: "主助手不能归档" } };
|
|
169
167
|
const live = store
|
|
170
168
|
.jobs()
|
|
171
169
|
.filter((job) => job.assistantId === id && (job.status === "running" || job.status === "queued" || job.status === "waiting"));
|
|
172
|
-
if (live.length) return { status: 409, body: { error: "
|
|
173
|
-
|
|
174
|
-
// 先算清楚要转多少:规则同一条文字只留一份(主助手原来那条优先)
|
|
175
|
-
const moved = store.routines().filter((routine) => routine.assistantId === id);
|
|
176
|
-
const existing = store.rules(primary.id);
|
|
177
|
-
const now = new Date().toISOString();
|
|
178
|
-
const added: Rule[] = [];
|
|
179
|
-
for (const rule of store.rules(id)) {
|
|
180
|
-
const text = rule.text.trim();
|
|
181
|
-
if (!text || existing.some((item) => item.text.trim() === text)) continue;
|
|
182
|
-
const next: Rule = { id: newId("r-"), text, mode: rule.mode, changedAt: now };
|
|
183
|
-
existing.push(next);
|
|
184
|
-
added.push(next);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 主助手对话里那行提示:模型没配好时开不了会话,这里就停下,别留下改了一半的状态
|
|
188
|
-
try {
|
|
189
|
-
await router.addItem(primary.id, {
|
|
190
|
-
kind: "note",
|
|
191
|
-
icon: "split",
|
|
192
|
-
text: `已并回「${target.name}」:转来 ${moved.length} 个定时任务、${added.length} 条规则`,
|
|
193
|
-
});
|
|
194
|
-
} catch (error) {
|
|
195
|
-
return { status: 409, body: { error: `无法写入 Hob 的对话:${error instanceof Error ? error.message : String(error)}` } };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
for (const routine of moved) {
|
|
199
|
-
const next = { ...routine, assistantId: primary.id };
|
|
200
|
-
store.saveRoutine(next);
|
|
201
|
-
bus.emit({ type: "routine", routine: next });
|
|
202
|
-
}
|
|
203
|
-
// cron 的归属和时区都在建的时候定死,改完所属助手重排一遍
|
|
204
|
-
if (moved.length) routines.reload();
|
|
205
|
-
|
|
206
|
-
if (added.length) {
|
|
207
|
-
store.saveRules(primary.id, existing);
|
|
208
|
-
bus.emit({ type: "profile", assistantId: primary.id });
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const extra = target.instructions.trim();
|
|
212
|
-
const instructions = extra
|
|
213
|
-
? [primary.instructions.trim(), `来自「${target.name}」:`, extra].filter(Boolean).join("\n")
|
|
214
|
-
: primary.instructions;
|
|
215
|
-
store.saveAssistant({ ...primary, instructions });
|
|
170
|
+
if (live.length) return { status: 409, body: { error: "请等任务结束后再归档" } };
|
|
216
171
|
|
|
217
172
|
// 这个助手挂着没答的审批:助手没了,卡片一并结束
|
|
218
173
|
for (const approval of store.approvals()) {
|
|
@@ -228,10 +183,15 @@ export async function mergeAssistant(runtime: HobRuntime, id: string): Promise<A
|
|
|
228
183
|
store.saveJob({ ...job, assistantName: target.name });
|
|
229
184
|
}
|
|
230
185
|
|
|
186
|
+
// 公共屏幕授权不随助手 id 的重新使用继承。
|
|
187
|
+
const desktop = runtime.settings.settings.desktop;
|
|
188
|
+
if (desktop?.publicAssistants?.includes(id)) {
|
|
189
|
+
runtime.settings.setDesktop(runtime.settings.get().revision, { ...desktop, publicAssistants: desktop.publicAssistants.filter((assistantId) => assistantId !== id) });
|
|
190
|
+
}
|
|
231
191
|
router.forget(id);
|
|
232
|
-
store.
|
|
192
|
+
const routines = store.routines().filter((routine) => routine.assistantId === id);
|
|
193
|
+
store.archiveAssistant(id, `${id}-${new Date().toISOString().replace(/[:.]/g, "-")}`, routines);
|
|
194
|
+
for (const routine of routines) runtime.routines.remove(routine.id);
|
|
233
195
|
bus.emit({ type: "assistant_removed", id });
|
|
234
|
-
|
|
235
|
-
bus.emit({ type: "assistant", assistant: merged });
|
|
236
|
-
return { status: 200, body: { assistant: merged } };
|
|
196
|
+
return { status: 200, body: { ok: true } };
|
|
237
197
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { loadSkillsFromDir, type Skill } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { AssistantSkill } from "../../shared/skills.ts";
|
|
5
|
+
|
|
6
|
+
type Metadata = Omit<AssistantSkill, "name" | "description" | "path">;
|
|
7
|
+
export interface SkillInput { name: string; title: string; description: string; content: string; files?: { path: string; content: string }[] }
|
|
8
|
+
|
|
9
|
+
function checkedDir(home: string, assistantId: string): string {
|
|
10
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(assistantId)) throw new Error("助手名称无效");
|
|
11
|
+
const root = resolve(home);
|
|
12
|
+
const dir = join(root, "assistants", assistantId, "skills");
|
|
13
|
+
let current = root;
|
|
14
|
+
for (const part of ["assistants", assistantId, "skills"]) {
|
|
15
|
+
current = join(current, part);
|
|
16
|
+
if (isLink(current)) throw new Error("技能路径不能包含符号链接");
|
|
17
|
+
}
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
function validName(name: string): void {
|
|
21
|
+
if (!/^[a-z0-9][a-z0-9-]{1,47}$/.test(name)) throw new Error("技能名称无效");
|
|
22
|
+
}
|
|
23
|
+
function isLink(path: string): boolean {
|
|
24
|
+
try { return lstatSync(path).isSymbolicLink(); }
|
|
25
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
26
|
+
}
|
|
27
|
+
function safeTree(path: string): void {
|
|
28
|
+
if (isLink(path)) throw new Error("技能目录不能包含符号链接");
|
|
29
|
+
if (!existsSync(path)) return;
|
|
30
|
+
const stat = lstatSync(path);
|
|
31
|
+
if (stat.isSymbolicLink()) throw new Error("技能目录不能包含符号链接");
|
|
32
|
+
if (stat.isDirectory()) for (const entry of readdirSync(path)) safeTree(join(path, entry));
|
|
33
|
+
}
|
|
34
|
+
function metadata(dir: string): Record<string, Metadata> {
|
|
35
|
+
const path = join(dir, "skills.json");
|
|
36
|
+
safeTree(path);
|
|
37
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
38
|
+
}
|
|
39
|
+
function saveMetadata(dir: string, data: Record<string, Metadata>): void {
|
|
40
|
+
const temp = join(dir, `.${process.pid}-${Date.now()}.tmp`);
|
|
41
|
+
writeFileSync(temp, JSON.stringify(data, null, 2));
|
|
42
|
+
renameSync(temp, join(dir, "skills.json"));
|
|
43
|
+
}
|
|
44
|
+
function description(file: string): string {
|
|
45
|
+
const line = readFileSync(file, "utf8").split("\n").find((item) => item.startsWith("description: "));
|
|
46
|
+
if (!line) throw new Error("技能说明缺失");
|
|
47
|
+
return JSON.parse(line.slice("description: ".length));
|
|
48
|
+
}
|
|
49
|
+
export function readAssistantSkillBody(path: string): string {
|
|
50
|
+
const text = readFileSync(path, "utf8");
|
|
51
|
+
const end = text.indexOf("\n---\n");
|
|
52
|
+
if (!text.startsWith("---\n") || end < 0) throw new Error("技能 frontmatter 无效");
|
|
53
|
+
return text.slice(end + 5).replace(/^\n/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function skillInstruction(title: string, instruction: string): string {
|
|
57
|
+
return `按技能「${title}」做(先读它的 SKILL.md)。\n${instruction}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listAssistantSkills(home: string, assistantId: string): AssistantSkill[] {
|
|
61
|
+
const dir = checkedDir(home, assistantId);
|
|
62
|
+
return Object.entries(metadata(dir)).map(([name, data]) => {
|
|
63
|
+
validName(name);
|
|
64
|
+
const skillDir = join(dir, name);
|
|
65
|
+
safeTree(skillDir);
|
|
66
|
+
const path = join(skillDir, "SKILL.md");
|
|
67
|
+
return { name, ...data, description: description(path), path };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** 给对话、任务和定时用的已启用技能:读不了就当没有并记日志,一个坏掉的技能不能让整个助手开不了会话。 */
|
|
71
|
+
export function usableAssistantSkills(home: string, assistantId: string): AssistantSkill[] {
|
|
72
|
+
try { return listAssistantSkills(home, assistantId).filter((skill) => skill.enabled); }
|
|
73
|
+
catch (error) { console.error(`读取「${assistantId}」的技能失败:`, error); return []; }
|
|
74
|
+
}
|
|
75
|
+
export function enabledAssistantSkills(home: string, assistantId: string): Skill[] {
|
|
76
|
+
return usableAssistantSkills(home, assistantId).flatMap((skill) =>
|
|
77
|
+
loadSkillsFromDir({ dir: dirname(skill.path), source: "assistant" }).skills.filter((item) => item.filePath === skill.path));
|
|
78
|
+
}
|
|
79
|
+
/** save_skill 的附件可以不抄全文,只给工作目录里的文件(from),这里读出来;不能跳出工作目录。 */
|
|
80
|
+
export function readSkillFiles(cwd: string, files: unknown): SkillInput["files"] {
|
|
81
|
+
if (files === undefined) return undefined;
|
|
82
|
+
if (!Array.isArray(files)) throw new Error("技能附件无效");
|
|
83
|
+
let root: string | undefined;
|
|
84
|
+
return files.map((file) => {
|
|
85
|
+
const { path, content, from } = (file ?? {}) as { path?: unknown; content?: unknown; from?: unknown };
|
|
86
|
+
// 有的模型会把每个字段都填上,没用的那个给空串:空串当没给
|
|
87
|
+
const hasFrom = from !== undefined && from !== "";
|
|
88
|
+
const hasContent = content !== undefined && !(hasFrom && content === "");
|
|
89
|
+
if (hasContent === hasFrom) throw new Error("附件要给 content 或 from,只给一个");
|
|
90
|
+
if (!hasFrom) return { path, content } as { path: string; content: string };
|
|
91
|
+
if (typeof from !== "string" || !from.trim()) throw new Error("附件的 from 无效");
|
|
92
|
+
let real: string;
|
|
93
|
+
try { root ??= realpathSync(cwd); real = realpathSync(resolve(root, from)); }
|
|
94
|
+
catch { throw new Error(`找不到 from 指向的文件:${from}`); }
|
|
95
|
+
const rel = relative(root, real);
|
|
96
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("from 只能是工作目录里的文件");
|
|
97
|
+
const stat = statSync(real);
|
|
98
|
+
if (!stat.isFile()) throw new Error(`from 不是文件:${from}`);
|
|
99
|
+
if (stat.size > 200 * 1024) throw new Error("技能附件超过 200 KB");
|
|
100
|
+
return { path, content: readFileSync(real, "utf8") } as { path: string; content: string };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function saveAssistantSkill(home: string, assistantId: string, input: SkillInput, jobId?: string, replaceFiles = false, options?: { overwriteVersion?: boolean }): AssistantSkill {
|
|
104
|
+
validName(input.name);
|
|
105
|
+
if (typeof input.title !== "string" || !input.title.trim() || typeof input.description !== "string" || !input.description.trim() || [...input.description].length > 120 || typeof input.content !== "string") throw new Error("技能内容无效");
|
|
106
|
+
const body = `---\nname: ${JSON.stringify(input.name)}\ndescription: ${JSON.stringify(input.description)}\nmetadata:\n title: ${JSON.stringify(input.title)}\n---\n\n${input.content}`;
|
|
107
|
+
if ([...body].length > 20000) throw new Error("SKILL.md 超过 20000 字");
|
|
108
|
+
if (input.files !== undefined && !Array.isArray(input.files)) throw new Error("技能附件无效");
|
|
109
|
+
const files = input.files ?? [];
|
|
110
|
+
if (files.length > 20) throw new Error("技能附件超过 20 个");
|
|
111
|
+
const names = new Set<string>();
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
if (!file || typeof file.path !== "string" || typeof file.content !== "string" || !/^(scripts|references)\/(?!.*(?:^|\/)\.\.?($|\/))[^\\\0]+$/.test(file.path) || file.path.split("/").some((p) => !p || p === "." || p === "..")) throw new Error("附件路径无效");
|
|
114
|
+
if (Buffer.byteLength(file.content) > 200 * 1024) throw new Error("技能附件超过 200 KB");
|
|
115
|
+
if (names.has(file.path)) throw new Error("附件路径重复");
|
|
116
|
+
names.add(file.path);
|
|
117
|
+
}
|
|
118
|
+
const dir = checkedDir(home, assistantId);
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
const all = metadata(dir);
|
|
121
|
+
const target = join(dir, input.name);
|
|
122
|
+
safeTree(target);
|
|
123
|
+
const old = all[input.name];
|
|
124
|
+
if (!old && existsSync(target)) throw new Error("技能目录已存在");
|
|
125
|
+
const existingFiles = new Set<string>();
|
|
126
|
+
const collectFiles = (dirPath: string, prefix: string): void => {
|
|
127
|
+
if (!existsSync(dirPath)) return;
|
|
128
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
129
|
+
const path = `${prefix}/${entry.name}`;
|
|
130
|
+
if (entry.isDirectory()) collectFiles(join(dirPath, entry.name), path);
|
|
131
|
+
else if (entry.isFile()) existingFiles.add(path);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
collectFiles(join(target, "scripts"), "scripts");
|
|
135
|
+
collectFiles(join(target, "references"), "references");
|
|
136
|
+
const combined = new Set([...(replaceFiles ? [] : existingFiles), ...names]);
|
|
137
|
+
if (combined.size > 20) throw new Error("技能附件超过 20 个");
|
|
138
|
+
for (const path of combined) {
|
|
139
|
+
const parts = path.split("/");
|
|
140
|
+
for (let i = 2; i < parts.length; i++) if (combined.has(parts.slice(0, i).join("/"))) throw new Error("附件路径冲突");
|
|
141
|
+
}
|
|
142
|
+
if (old && !options?.overwriteVersion) {
|
|
143
|
+
const history = join(target, ".history");
|
|
144
|
+
mkdirSync(history, { recursive: true });
|
|
145
|
+
const snapshot = join(history, String(old.version));
|
|
146
|
+
mkdirSync(snapshot);
|
|
147
|
+
for (const entry of readdirSync(target)) if (entry !== ".history") cpSync(join(target, entry), join(snapshot, entry), { recursive: true });
|
|
148
|
+
const versions = readdirSync(history).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a);
|
|
149
|
+
for (const version of versions.slice(5)) rmSync(join(history, String(version)), { recursive: true, force: true });
|
|
150
|
+
} else if (!old) mkdirSync(target);
|
|
151
|
+
if (replaceFiles) for (const folder of ["scripts", "references"]) rmSync(join(target, folder), { recursive: true, force: true });
|
|
152
|
+
writeFileSync(join(target, "SKILL.md"), body);
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
const dest = join(target, file.path);
|
|
155
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
156
|
+
writeFileSync(dest, file.content);
|
|
157
|
+
}
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
all[input.name] = { title: input.title, version: old && options?.overwriteVersion ? old.version : (old?.version ?? 0) + 1, createdAt: old?.createdAt ?? now, updatedAt: now, ...(jobId ? { fromJobId: jobId } : old?.fromJobId ? { fromJobId: old.fromJobId } : {}), uses: old?.uses ?? 0, ...(old?.lastUsedAt ? { lastUsedAt: old.lastUsedAt } : {}), enabled: old?.enabled ?? true };
|
|
160
|
+
saveMetadata(dir, all);
|
|
161
|
+
return listAssistantSkills(home, assistantId).find((skill) => skill.name === input.name)!;
|
|
162
|
+
}
|
|
163
|
+
export function setAssistantSkillEnabled(home: string, assistantId: string, name: string, enabled: boolean): AssistantSkill {
|
|
164
|
+
validName(name);
|
|
165
|
+
const dir = checkedDir(home, assistantId), all = metadata(dir);
|
|
166
|
+
if (!all[name]) throw new Error("技能不存在");
|
|
167
|
+
safeTree(join(dir, name));
|
|
168
|
+
all[name].enabled = enabled;
|
|
169
|
+
saveMetadata(dir, all);
|
|
170
|
+
return listAssistantSkills(home, assistantId).find((skill) => skill.name === name)!;
|
|
171
|
+
}
|
|
172
|
+
export function archiveAssistantSkill(home: string, assistantId: string, name: string): void {
|
|
173
|
+
validName(name);
|
|
174
|
+
const dir = checkedDir(home, assistantId), all = metadata(dir), target = join(dir, name);
|
|
175
|
+
if (!all[name]) throw new Error("技能不存在");
|
|
176
|
+
safeTree(target);
|
|
177
|
+
const archive = join(dir, ".archive");
|
|
178
|
+
safeTree(archive);
|
|
179
|
+
mkdirSync(archive, { recursive: true });
|
|
180
|
+
renameSync(target, join(archive, `${name}-${Date.now()}`));
|
|
181
|
+
delete all[name];
|
|
182
|
+
saveMetadata(dir, all);
|
|
183
|
+
}
|
|
184
|
+
export function recordAssistantSkillUse(home: string, assistantId: string, name: string): void {
|
|
185
|
+
validName(name);
|
|
186
|
+
const dir = checkedDir(home, assistantId), all = metadata(dir);
|
|
187
|
+
if (!all[name]) throw new Error("技能不存在");
|
|
188
|
+
all[name].uses++;
|
|
189
|
+
all[name].lastUsedAt = new Date().toISOString();
|
|
190
|
+
saveMetadata(dir, all);
|
|
191
|
+
}
|
|
192
|
+
export function rollbackAssistantSkill(home: string, assistantId: string, name: string): AssistantSkill {
|
|
193
|
+
validName(name);
|
|
194
|
+
const dir = checkedDir(home, assistantId), all = metadata(dir), target = join(dir, name);
|
|
195
|
+
if (!all[name]) throw new Error("技能不存在");
|
|
196
|
+
safeTree(target);
|
|
197
|
+
const history = join(target, ".history");
|
|
198
|
+
const versions = existsSync(history) ? readdirSync(history).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a) : [];
|
|
199
|
+
if (!versions.length) throw new Error("没有上一版");
|
|
200
|
+
const previous = join(history, String(versions[0]));
|
|
201
|
+
const body = readFileSync(join(previous, "SKILL.md"), "utf8");
|
|
202
|
+
const titleLine = body.split("\n").find((line) => line.startsWith(" title: "));
|
|
203
|
+
const title = titleLine ? JSON.parse(titleLine.slice(" title: ".length)) : all[name].title;
|
|
204
|
+
const files: { path: string; content: string }[] = [];
|
|
205
|
+
function collect(dirPath: string, rel: string): void {
|
|
206
|
+
if (!existsSync(dirPath)) return;
|
|
207
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
208
|
+
const path = join(dirPath, entry.name), next = `${rel}/${entry.name}`;
|
|
209
|
+
if (entry.isDirectory()) collect(path, next);
|
|
210
|
+
else if (entry.isFile()) files.push({ path: next, content: readFileSync(path, "utf8") });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
collect(join(previous, "scripts"), "scripts"); collect(join(previous, "references"), "references");
|
|
214
|
+
return saveAssistantSkill(home, assistantId, { name, title, description: description(join(previous, "SKILL.md")), content: body.slice(body.indexOf("\n---\n") + 5).replace(/^\n/, ""), files }, undefined, true);
|
|
215
|
+
}
|
package/server/src/boot.ts
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { applyEngineEnv } from "./boot.ts";
|
|
6
|
+
import type { ClaudeCodeStatus } from "../../shared/types.ts";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/** 找不到或不可执行时不回退到 SDK 自带的程序。 */
|
|
11
|
+
export function findClaude(path: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
|
|
12
|
+
const candidates = path ? [path] : (env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, "claude"));
|
|
13
|
+
return candidates.find((candidate) => {
|
|
14
|
+
if (!isAbsolute(candidate)) return false;
|
|
15
|
+
try { accessSync(candidate, constants.X_OK); return true; } catch { return false; }
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 只保留状态展示需要的字段,不返回程序的原始输出或错误。 */
|
|
20
|
+
export async function claudeStatus(home: string, path?: string, inherited = process.env): Promise<ClaudeCodeStatus> {
|
|
21
|
+
const env = { ...inherited };
|
|
22
|
+
applyEngineEnv(env, home);
|
|
23
|
+
const executable = findClaude(path, env);
|
|
24
|
+
if (!executable) return { found: false, loggedIn: false };
|
|
25
|
+
let version: string | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const result = await run(executable, ["--version"], { env, timeout: 10_000, maxBuffer: 16_384 });
|
|
28
|
+
version = result.stdout.match(/^\s*(\d+\.\d+\.\d+(?:[-.][\w.-]+)?)/)?.[1];
|
|
29
|
+
} catch {
|
|
30
|
+
return { found: true, executable, loggedIn: false, error: "无法运行 Claude Code,请检查程序路径。" };
|
|
31
|
+
}
|
|
32
|
+
let output: string;
|
|
33
|
+
try {
|
|
34
|
+
output = (await run(executable, ["auth", "status", "--json"], { env, timeout: 10_000, maxBuffer: 16_384 })).stdout;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
output = typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "";
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(output) as Record<string, unknown>;
|
|
40
|
+
if (typeof value.loggedIn !== "boolean") throw new Error("invalid status");
|
|
41
|
+
const email = typeof value.email === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email) && value.email.length <= 254 ? value.email : undefined;
|
|
42
|
+
const plan = typeof value.subscriptionType === "string" && /^(pro|max|team|enterprise)$/i.test(value.subscriptionType) ? value.subscriptionType : undefined;
|
|
43
|
+
return { found: true, executable, ...(version ? { version } : {}), loggedIn: value.loggedIn === true, ...(email ? { email } : {}), ...(plan ? { plan } : {}) };
|
|
44
|
+
} catch {
|
|
45
|
+
return { found: true, executable, ...(version ? { version } : {}), loggedIn: false, error: "无法检测 Claude Code 登录状态。" };
|
|
46
|
+
}
|
|
47
|
+
}
|
package/server/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hashPassword, isPasswordHash } from "./password.ts";
|
|
@@ -65,6 +65,8 @@ function writeAtomic(path: string, text: string): void {
|
|
|
65
65
|
*/
|
|
66
66
|
export function loadConfig(home: string, env: NodeJS.ProcessEnv = process.env): HobConfig {
|
|
67
67
|
mkdirSync(home, { recursive: true });
|
|
68
|
+
// 对话、任务、引擎日志(MCP 服务发来的日志原样落盘)都在这里:只给本人读
|
|
69
|
+
chmodSync(home, 0o700);
|
|
68
70
|
const file = join(home, CONFIG_FILE);
|
|
69
71
|
const stored: Record<string, unknown> = existsSync(file)
|
|
70
72
|
? (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import type { Delegation } from "../../shared/types.ts";
|
|
3
2
|
import { newId } from "./ids.ts";
|
|
3
|
+
import { jobResultEvent } from "./jobs.ts";
|
|
4
4
|
import type { HobRuntime } from "./runtime.ts";
|
|
5
5
|
|
|
6
6
|
/** 转交状态由目标回合、任务和审批共同决定;判定和标记回流不跨异步边界。 */
|
|
@@ -19,25 +19,40 @@ export class DelegationManager {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async start(to: string, text: string): Promise<Delegation> {
|
|
22
|
+
async start(to: string, text: string, files: string[] = []): Promise<Delegation> {
|
|
23
23
|
const target = this.runtime.store.assistant(to);
|
|
24
24
|
if (!target || to === "hob") throw new Error("目标助手不存在或不能转交给自己。");
|
|
25
25
|
const delegation: Delegation = { id: newId("dg-"), from: "hob", to, text, createdAt: new Date().toISOString(), status: "running", jobIds: [], approvalIds: [] };
|
|
26
26
|
this.runtime.store.saveDelegation(delegation);
|
|
27
27
|
await this.runtime.router.addItem("hob", { kind: "delegation", delegationId: delegation.id });
|
|
28
|
+
let sourceUserId: string;
|
|
28
29
|
try {
|
|
29
|
-
await this.runtime.router.addForwarded(to, text, delegation.id);
|
|
30
|
+
const forwarded = await this.runtime.router.addForwarded(to, text, delegation.id, "hob", files);
|
|
31
|
+
sourceUserId = forwarded.id;
|
|
30
32
|
} catch (error) {
|
|
31
33
|
this.turnEnded(delegation.id, error instanceof Error ? error.message : String(error), true);
|
|
32
34
|
throw error;
|
|
33
35
|
}
|
|
34
36
|
this.runtime.bus.emit({ type: "delegation", delegation });
|
|
35
|
-
void this.runtime.router.relay(to, `转自 Hob:${text}`, delegation.id).catch((error: unknown) => {
|
|
37
|
+
void this.runtime.router.relay(to, `转自 Hob:${text}`, delegation.id, { sourceUserId }, text).catch((error: unknown) => {
|
|
36
38
|
this.turnEnded(delegation.id, error instanceof Error ? error.message : String(error), true);
|
|
37
39
|
});
|
|
38
40
|
return delegation;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
takeOver(to: string): void {
|
|
44
|
+
const { store, bus, router } = this.runtime;
|
|
45
|
+
for (const record of store.delegations()) {
|
|
46
|
+
if (record.to !== to || record.reportedAt || record.status === "interrupted") continue;
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const delegation: Delegation = { ...record, status: "takenOver", endedAt: now, reportedAt: now };
|
|
49
|
+
store.saveDelegation(delegation);
|
|
50
|
+
bus.emit({ type: "delegation", delegation });
|
|
51
|
+
const name = store.assistant(to)?.name ?? to;
|
|
52
|
+
void router.event(record.from, `转交给「${name}」的事已被用户接手。原请求:${record.text}`).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
addJob(id: string, jobId: string): void {
|
|
42
57
|
const record = this.runtime.store.delegation(id);
|
|
43
58
|
if (!record || record.jobIds.includes(jobId)) return;
|
|
@@ -98,16 +113,11 @@ export class DelegationManager {
|
|
|
98
113
|
if (complete) {
|
|
99
114
|
const reports = jobs.map((job) => {
|
|
100
115
|
if (!job) return "任务记录丢失";
|
|
101
|
-
|
|
102
|
-
if (job.reportPath) {
|
|
103
|
-
try { report = readFileSync(job.reportPath, "utf8"); }
|
|
104
|
-
catch (error) { report = `报告读取失败:${error instanceof Error ? error.message : String(error)};摘要:${report}`; }
|
|
105
|
-
}
|
|
106
|
-
return `任务「${job.title}」${job.status}:${report.length > 6000 ? `${report.slice(0, 6000)}…(报告过长已截断,完整内容在任务详情)` : report}`;
|
|
116
|
+
return jobResultEvent(job);
|
|
107
117
|
});
|
|
108
118
|
const name = store.assistant(record.to)?.name ?? record.to;
|
|
109
119
|
const message = record.status === "returned"
|
|
110
|
-
? `转交给「${name}」的事已退回:${record.reply}。原请求:${record.text}\n交回内容:${record.returnedText ?? record.text}\n「${name}
|
|
120
|
+
? `转交给「${name}」的事已退回:${record.reply}。原请求:${record.text}\n交回内容:${record.returnedText ?? record.text}\n「${name}」已退回过一次。`
|
|
111
121
|
: `转交给「${name}」的事已结束:${record.reply || "(没有回复)"}`;
|
|
112
122
|
void router.event(record.from, `${message}\n${reports.join("\n")}`).catch(() => undefined);
|
|
113
123
|
}
|