hob-bot 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +75 -5
  2. package/README.zh-CN.md +75 -5
  3. package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
  4. package/dist/web/assets/index-B-8ISOr1.js +267 -0
  5. package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
  13. package/package.json +10 -2
  14. package/server/src/actions.ts +24 -57
  15. package/server/src/assistant-skills.ts +201 -0
  16. package/server/src/boot.ts +1 -0
  17. package/server/src/claude-status.ts +47 -0
  18. package/server/src/config.ts +3 -1
  19. package/server/src/delegations.ts +40 -12
  20. package/server/src/desktop-control.ts +152 -0
  21. package/server/src/desktop-events.ts +63 -0
  22. package/server/src/desktop-public.ts +43 -0
  23. package/server/src/desktop-tools.ts +161 -0
  24. package/server/src/desktop.ts +76 -0
  25. package/server/src/embedding.ts +52 -0
  26. package/server/src/files.ts +63 -43
  27. package/server/src/http.ts +334 -15
  28. package/server/src/jobs.ts +330 -59
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +1 -1
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +46 -154
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +42 -7
  43. package/server/src/router-activity.ts +4 -0
  44. package/server/src/router-tools.ts +132 -38
  45. package/server/src/router.ts +159 -19
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/thumbs.ts +49 -0
  51. package/server/src/transcript.ts +57 -4
  52. package/server/src/worker-tools.ts +14 -12
  53. package/shared/knowledge.ts +92 -0
  54. package/shared/mcp.ts +91 -0
  55. package/shared/media.ts +6 -0
  56. package/shared/plain-text.ts +31 -0
  57. package/shared/skills.ts +18 -0
  58. package/shared/sprites.ts +198 -0
  59. package/shared/types.ts +105 -20
  60. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  61. package/vendor/claude-bridge/LICENSE +21 -0
  62. package/vendor/claude-bridge/package.json +62 -0
  63. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  64. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  65. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  66. package/vendor/claude-bridge/src/attachments.ts +135 -0
  67. package/vendor/claude-bridge/src/config.ts +105 -0
  68. package/vendor/claude-bridge/src/convert.ts +222 -0
  69. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  70. package/vendor/claude-bridge/src/index.ts +2503 -0
  71. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  72. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  73. package/vendor/claude-bridge/src/models.ts +164 -0
  74. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  75. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  76. package/vendor/claude-bridge/src/query-state.ts +118 -0
  77. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  78. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  79. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  80. package/vendor/claude-bridge/src/skills.ts +20 -0
  81. package/vendor/claude-bridge/src/transcript.ts +86 -0
  82. package/vendor/claude-bridge/src/usage.ts +53 -0
  83. package/vendor/lore-pi-extension/package.json +1 -1
  84. package/vendor/lore-pi-extension/tools.ts +8 -5
  85. package/dist/web/assets/index-C72gAwgN.css +0 -1
  86. package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
@@ -1,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, 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;
@@ -54,10 +69,26 @@ export class DelegationManager {
54
69
  turnEnded(id: string, reply: string, failed = false): void {
55
70
  const record = this.runtime.store.delegation(id);
56
71
  if (!record || record.turnFinished) return;
57
- this.runtime.store.saveDelegation({ ...record, reply, turnFinished: true, ...(failed ? { turnFailed: true } : {}) });
72
+ const replies = [record.reply, reply].filter(Boolean).join("\n");
73
+ this.runtime.store.saveDelegation({ ...record, reply: replies, turnFinished: true, ...(failed ? { turnFailed: true } : {}) });
58
74
  this.update(id);
59
75
  }
60
76
 
77
+ /**
78
+ * 转交里派的任务结束了、或用户答了转交里的提问:结果先回到目标助手,它这一轮可能接着再派任务、再问用户。
79
+ * 这一轮跑完、名下没有在跑的任务,转交才算结束,Hob 才收到结果;
80
+ * 否则目标助手还在处理时 Hob 就会收到「已结束」,另开任务和它冲突。
81
+ * 调用方要在发出 job/approval 事件之前调用,再用 router.relay 把结果送给目标助手。
82
+ * 返回 false 表示转交已经回流过(或已退回),结果按普通事件处理。
83
+ */
84
+ resume(id: string): boolean {
85
+ const record = this.runtime.store.delegation(id);
86
+ if (!record || record.reportedAt || record.status === "returned" || record.status === "interrupted") return false;
87
+ const { turnFailed: _failed, ...rest } = record;
88
+ this.runtime.store.saveDelegation({ ...rest, turnFinished: false });
89
+ return true;
90
+ }
91
+
61
92
  returnToHob(id: string, text: string, reason: string): void {
62
93
  const record = this.runtime.store.delegation(id);
63
94
  if (!record || record.turnFinished) throw new Error("转交已结束,无法退回");
@@ -82,16 +113,11 @@ export class DelegationManager {
82
113
  if (complete) {
83
114
  const reports = jobs.map((job) => {
84
115
  if (!job) return "任务记录丢失";
85
- let report = job.summary ?? job.statusReason ?? "无报告";
86
- if (job.reportPath) {
87
- try { report = readFileSync(job.reportPath, "utf8"); }
88
- catch (error) { report = `报告读取失败:${error instanceof Error ? error.message : String(error)};摘要:${report}`; }
89
- }
90
- return `任务「${job.title}」${job.status}:${report.length > 6000 ? `${report.slice(0, 6000)}…(报告过长已截断,完整内容在任务详情)` : report}`;
116
+ return jobResultEvent(job);
91
117
  });
92
118
  const name = store.assistant(record.to)?.name ?? record.to;
93
119
  const message = record.status === "returned"
94
- ? `转交给「${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}」已退回过一次。`
95
121
  : `转交给「${name}」的事已结束:${record.reply || "(没有回复)"}`;
96
122
  void router.event(record.from, `${message}\n${reports.join("\n")}`).catch(() => undefined);
97
123
  }
@@ -102,6 +128,8 @@ export class DelegationManager {
102
128
  for (const record of this.runtime.store.delegations()) {
103
129
  if (record.reportedAt || !["running", "waiting", "returned"].includes(record.status)) continue;
104
130
  if (record.jobIds.some((id) => { const job = this.runtime.store.job(id); return job && ["running", "queued", "waiting"].includes(job.status); })) continue;
131
+ // 目标助手在等用户选:审批卡重启后还在,答了照常继续,不算中断
132
+ if (record.approvalIds?.some((id) => this.runtime.store.approval(id)?.status === "pending")) continue;
105
133
  const now = new Date().toISOString();
106
134
  const next: Delegation = { ...record, status: "interrupted", endedAt: now, reportedAt: now };
107
135
  this.runtime.store.saveDelegation(next);
@@ -0,0 +1,152 @@
1
+ import { WebSocket, WebSocketServer } from "ws";
2
+ import type { IncomingMessage } from "node:http";
3
+ import type { serve } from "@hono/node-server";
4
+ import type { Duplex } from "node:stream";
5
+ import type { HobRuntime } from "./runtime.ts";
6
+ import { validCookie } from "./http.ts";
7
+ import { desktopProfile } from "./desktop-tools.ts";
8
+
9
+ const CLIENT = /^[a-f0-9-]{36}$/;
10
+
11
+ export class DesktopControl {
12
+ private screens = new Map<string, { owner: string | null; connections: Map<string, number>; timer?: NodeJS.Timeout }>();
13
+ constructor(private runtime: HobRuntime) {}
14
+ private screen(id: string) {
15
+ let screen = this.screens.get(id);
16
+ if (!screen) { screen = { owner: null, connections: new Map() }; this.screens.set(id, screen); }
17
+ return screen;
18
+ }
19
+ state(client: string, id = "hob") { const screen = this.screen(id); return { taken: screen.owner !== null, mine: screen.owner === client }; }
20
+ private emit(id: string) { this.runtime.bus.emit({ type: "desktop-control", screen: id, taken: this.screen(id).owner !== null }); }
21
+ take(client: string, id = "hob") {
22
+ const screen = this.screen(id);
23
+ if (!CLIENT.test(client) || !screen.connections.has(client)) throw new Error("请先打开云桌面。");
24
+ if (screen.owner && screen.owner !== client) throw new Error("另一位用户正在操作云桌面。");
25
+ screen.owner = client;
26
+ if (screen.timer) clearTimeout(screen.timer);
27
+ screen.timer = undefined;
28
+ this.emit(id);
29
+ }
30
+ release(client: string, id = "hob") {
31
+ const screen = this.screen(id);
32
+ if (screen.owner !== client) throw new Error("当前设备没有接管云桌面。");
33
+ screen.owner = null;
34
+ if (screen.timer) clearTimeout(screen.timer);
35
+ screen.timer = undefined;
36
+ this.emit(id);
37
+ }
38
+ connected(client: string, id = "hob") {
39
+ const screen = this.screen(id);
40
+ screen.connections.set(client, (screen.connections.get(client) ?? 0) + 1);
41
+ if (screen.owner === client && screen.timer) { clearTimeout(screen.timer); screen.timer = undefined; }
42
+ }
43
+ disconnected(client: string, id = "hob") {
44
+ const screen = this.screen(id);
45
+ const count = (screen.connections.get(client) ?? 1) - 1;
46
+ if (count > 0) screen.connections.set(client, count);
47
+ else {
48
+ screen.connections.delete(client);
49
+ if (screen.owner === client) {
50
+ screen.timer = setTimeout(() => { if (screen.owner === client && !screen.connections.has(client)) { screen.owner = null; this.emit(id); } }, 60_000);
51
+ screen.timer.unref();
52
+ }
53
+ }
54
+ }
55
+ get taken() { return this.screen("hob").owner !== null; }
56
+ dispose() { for (const screen of this.screens.values()) if (screen.timer) clearTimeout(screen.timer); this.screens.clear(); }
57
+ }
58
+
59
+ // RFB 3.8: version, security type and ClientInit, then length-delimited client messages.
60
+ // Parse the stream rather than trusting WebSocket frame boundaries so view-only cannot inject input.
61
+ export class RfbClientFilter {
62
+ private data = Buffer.alloc(0);
63
+ private phase = 0;
64
+ constructor(private allowed: () => boolean) {}
65
+ push(input: Buffer): Buffer[] {
66
+ this.data = Buffer.concat([this.data, input]);
67
+ if (this.data.length > 1_048_576) throw new Error("RFB 消息过大");
68
+ const output: Buffer[] = [];
69
+ while (this.data.length) {
70
+ let size: number;
71
+ let isInput = false;
72
+ if (this.phase === 0) {
73
+ size = 12;
74
+ if (this.data.length < size) break;
75
+ if (this.data.subarray(0, 12).toString() !== "RFB 003.008\n") throw new Error("不支持的 RFB 协议");
76
+ } else if (this.phase === 1 || this.phase === 2) {
77
+ size = 1;
78
+ if (this.phase === 1 && this.data[0] !== 1) throw new Error("不支持的 RFB 认证方式");
79
+ } else {
80
+ const type = this.data[0];
81
+ switch (type) {
82
+ case 0: size = 20; break;
83
+ case 2: if (this.data.length < 4) return output; size = 4 + this.data.readUInt16BE(2) * 4; break;
84
+ case 3: size = 10; break;
85
+ case 4: size = 8; isInput = true; break;
86
+ case 5: if (this.data.length < 2) return output; size = (this.data[1] & 0x80) ? 7 : 6; isInput = true; break;
87
+ case 6: if (this.data.length < 8) return output; size = 8 + Math.abs(this.data.readInt32BE(4)); isInput = true; break;
88
+ case 150: size = 10; break;
89
+ case 248: if (this.data.length < 9) return output; size = 9 + this.data[8]; break;
90
+ case 251: if (this.data.length < 8) return output; size = 8 + this.data[6] * 16; isInput = true; break;
91
+ case 255: size = 12; isInput = true; break;
92
+ default: throw new Error(`不支持的 RFB 消息 ${type}`);
93
+ }
94
+ }
95
+ if (size > 1_048_576) throw new Error("RFB 消息过大");
96
+ if (this.data.length < size) break;
97
+ const message = this.data.subarray(0, size);
98
+ this.data = this.data.subarray(size);
99
+ if (this.phase < 3) this.phase++;
100
+ if (!isInput || this.allowed()) output.push(message);
101
+ }
102
+ return output;
103
+ }
104
+ }
105
+
106
+ export function attachDesktopWebSocket(server: ReturnType<typeof serve>, runtime: HobRuntime): () => void {
107
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1_048_576 });
108
+ const upgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
109
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
110
+ if (requestUrl.pathname !== "/api/desktop/vnc") return;
111
+ const reject = (status: string) => { socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
112
+ const cookie = req.headers.cookie?.split(";").map((s) => s.trim()).find((s) => s.startsWith("hob="))?.slice(4);
113
+ if (!validCookie(runtime.config, cookie)) return reject("401 Unauthorized");
114
+ const origin = req.headers.origin;
115
+ const forwardedHeader = req.headers["x-forwarded-host"];
116
+ const forwardedHost = (Array.isArray(forwardedHeader) ? forwardedHeader[0] : forwardedHeader)?.split(",")[0].trim();
117
+ try { if (origin && ![req.headers.host, forwardedHost].includes(new URL(origin).host)) return reject("403 Forbidden"); }
118
+ catch { return reject("403 Forbidden"); }
119
+ const client = requestUrl.searchParams.get("client") ?? "";
120
+ const screen = requestUrl.searchParams.get("screen") ?? "";
121
+ if (!CLIENT.test(client) || !/^[a-z0-9-]{1,64}$/.test(screen)) return reject("400 Bad Request");
122
+ if (screen !== "public" && !runtime.store.assistants().some((assistant) => desktopProfile(assistant.id) === screen)) return reject("404 Not Found");
123
+ let connection: { url: string; token: string };
124
+ try { connection = runtime.desktop.connection(); } catch { return reject("503 Service Unavailable"); }
125
+ const target = new URL(`/vnc/${screen}`, connection.url);
126
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
127
+ const upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${connection.token}` }, perMessageDeflate: false });
128
+ const failed = () => reject("502 Bad Gateway");
129
+ upstream.once("error", failed);
130
+ socket.once("close", () => { if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate(); });
131
+ upstream.once("open", () => {
132
+ upstream.off("error", failed);
133
+ wss.handleUpgrade(req, socket, head, (ws) => {
134
+ runtime.desktopControl.connected(client, screen);
135
+ const filter = new RfbClientFilter(() => runtime.desktopControl.state(client, screen).mine);
136
+ upstream.on("message", (data, binary) => { if (ws.readyState === WebSocket.OPEN) ws.send(data, { binary }); });
137
+ ws.on("message", (data, binary) => {
138
+ try {
139
+ if (!binary) throw new Error("RFB 需要二进制数据");
140
+ for (const message of filter.push(Buffer.from(data as Buffer))) if (upstream.readyState === WebSocket.OPEN) upstream.send(message);
141
+ } catch (error) { console.error("云桌面协议错误:", error instanceof Error ? error.message : String(error)); ws.close(1002, "RFB 协议错误"); }
142
+ });
143
+ ws.on("error", () => upstream.close());
144
+ upstream.on("error", () => ws.close());
145
+ ws.on("close", () => { runtime.desktopControl.disconnected(client, screen); upstream.close(); });
146
+ upstream.on("close", () => ws.close());
147
+ });
148
+ });
149
+ };
150
+ server.on("upgrade", upgrade);
151
+ return () => { server.off("upgrade", upgrade); for (const client of wss.clients) client.terminate(); wss.close(); };
152
+ }
@@ -0,0 +1,63 @@
1
+ import { WebSocket } from "ws";
2
+ import type { DesktopScreen } from "../../shared/types.ts";
3
+ import type { HobRuntime } from "./runtime.ts";
4
+
5
+ /** 监听 desktopd 的屏幕快照,连接中断时清空并退避重连。 */
6
+ export class DesktopEvents {
7
+ private socket?: WebSocket;
8
+ private timer?: NodeJS.Timeout;
9
+ private generation = 0;
10
+ private delay = 1000;
11
+ screens: DesktopScreen[] = [];
12
+ constructor(private runtime: HobRuntime) {}
13
+
14
+ restart() {
15
+ this.stop();
16
+ this.delay = 1000;
17
+ this.connect(this.generation);
18
+ }
19
+
20
+ stop() {
21
+ this.generation++;
22
+ if (this.timer) clearTimeout(this.timer);
23
+ this.timer = undefined;
24
+ this.socket?.terminate();
25
+ this.socket = undefined;
26
+ this.publish([]);
27
+ }
28
+
29
+ private publish(screens: DesktopScreen[]) {
30
+ this.screens = screens;
31
+ this.runtime.bus.emit({ type: "desktop-screens", screens });
32
+ }
33
+
34
+ private connect(generation: number) {
35
+ let connection: { url: string; token: string };
36
+ try { connection = this.runtime.desktop.connection(); } catch { this.retry(generation); return; }
37
+ const url = new URL("/api/events", connection.url);
38
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
39
+ const socket = new WebSocket(url, { headers: { Authorization: `Bearer ${connection.token}` } });
40
+ this.socket = socket;
41
+ socket.on("message", (data) => {
42
+ if (generation !== this.generation) return;
43
+ try {
44
+ const value = JSON.parse(data.toString()) as { type?: string; screens?: DesktopScreen[] };
45
+ if (value.type === "screens" && Array.isArray(value.screens)) { this.delay = 1000; this.publish(value.screens); }
46
+ } catch { /* 忽略无效事件,保留上次快照 */ }
47
+ });
48
+ socket.on("error", () => {});
49
+ socket.on("close", () => {
50
+ if (generation !== this.generation) return;
51
+ this.socket = undefined;
52
+ this.publish([]);
53
+ this.retry(generation);
54
+ });
55
+ }
56
+
57
+ private retry(generation: number) {
58
+ if (generation !== this.generation) return;
59
+ this.timer = setTimeout(() => this.connect(generation), this.delay);
60
+ this.timer.unref();
61
+ this.delay = Math.min(this.delay * 2, 30_000);
62
+ }
63
+ }
@@ -0,0 +1,43 @@
1
+ interface Waiter {
2
+ owner: object;
3
+ name: string;
4
+ resolve: () => void;
5
+ reject: (error: Error) => void;
6
+ timer: NodeJS.Timeout;
7
+ }
8
+
9
+ /** 公共屏幕由任务持有至结束;其他任务按到达顺序等待。 */
10
+ export class DesktopPublicLock {
11
+ private current?: { owner: object; name: string };
12
+ private waiters: Waiter[] = [];
13
+ constructor(private timeoutMs = 10 * 60_000) {}
14
+
15
+ acquire(owner: object, name: string): Promise<void> {
16
+ if (this.current?.owner === owner) return Promise.resolve();
17
+ if (!this.current) { this.current = { owner, name }; return Promise.resolve(); }
18
+ return new Promise((resolve, reject) => {
19
+ const waiter: Waiter = { owner, name, resolve, reject, timer: setTimeout(() => {
20
+ this.waiters = this.waiters.filter((entry) => entry !== waiter);
21
+ reject(new Error(`公共屏幕正被『${this.current?.name ?? "其他助手"}』的任务使用`));
22
+ }, this.timeoutMs) };
23
+ waiter.timer.unref();
24
+ this.waiters.push(waiter);
25
+ });
26
+ }
27
+
28
+ cancelWait(owner: object) {
29
+ for (const waiter of this.waiters.filter((entry) => entry.owner === owner)) {
30
+ clearTimeout(waiter.timer);
31
+ waiter.reject(new Error("任务已结束。"));
32
+ }
33
+ this.waiters = this.waiters.filter((entry) => entry.owner !== owner);
34
+ }
35
+
36
+ release(owner: object) {
37
+ this.cancelWait(owner);
38
+ if (this.current?.owner !== owner) return;
39
+ this.current = undefined;
40
+ const next = this.waiters.shift();
41
+ if (next) { clearTimeout(next.timer); this.current = { owner: next.owner, name: next.name }; next.resolve(); }
42
+ }
43
+ }
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { chromium, type Browser, type Page } from "playwright-core";
5
+ import type { HobRuntime } from "./runtime.ts";
6
+
7
+ const LOGIN = "需要登录的页面不要自己登录,用 ask_user 请用户打开云桌面登录后回复。";
8
+ const TAKEN = "用户正在操作云桌面,请稍后再试或用 ask_user 询问";
9
+ const point = Type.Object({ x: Type.Integer(), y: Type.Integer() });
10
+ const action = Type.Object({
11
+ type: Type.Union([Type.Literal("click"), Type.Literal("move"), Type.Literal("drag"), Type.Literal("type"), Type.Literal("key"), Type.Literal("scroll"), Type.Literal("wait")]),
12
+ x: Type.Optional(Type.Integer()), y: Type.Optional(Type.Integer()),
13
+ from: Type.Optional(point), to: Type.Optional(point),
14
+ button: Type.Optional(Type.Union([Type.Literal("left"), Type.Literal("right"), Type.Literal("middle")])),
15
+ count: Type.Optional(Type.Integer()), text: Type.Optional(Type.String()), keys: Type.Optional(Type.String()),
16
+ dy: Type.Optional(Type.Integer()), dx: Type.Optional(Type.Integer()), ms: Type.Optional(Type.Integer()),
17
+ });
18
+
19
+ type PageConnection = { browser?: Browser; page?: Page; opening?: Promise<Page> };
20
+
21
+ export class DesktopToolSession {
22
+ private connections = new Map<string, PageConnection>();
23
+ private publicWait?: Promise<void>;
24
+ private active = new Set<Promise<unknown>>();
25
+ private disposed = false;
26
+ private profile: string;
27
+ constructor(private runtime: HobRuntime, private assistantId: string, private canSeeImages: boolean) {
28
+ this.profile = desktopProfile(assistantId);
29
+ }
30
+
31
+ private check(screen: string) {
32
+ if (this.disposed) throw new Error("任务已结束。");
33
+ if (this.runtime.desktopControl.state("", screen).taken) throw new Error(TAKEN);
34
+ }
35
+ private async screen(value: unknown): Promise<string> {
36
+ if (value !== undefined && value !== "own" && value !== "public") throw new Error("屏幕参数无效。");
37
+ const screen = value === "public" ? "public" : this.profile;
38
+ this.check(screen);
39
+ if (screen === "public") {
40
+ if (!this.runtime.settings.settings.desktop?.publicAssistants?.includes(this.assistantId)) throw new Error("这个助手没有使用公共屏幕的权限。");
41
+ if (!this.publicWait) {
42
+ const name = this.runtime.store.assistant(this.assistantId)?.name ?? this.assistantId;
43
+ this.publicWait = this.runtime.desktopPublic.acquire(this, name).catch((error) => { this.publicWait = undefined; throw error; });
44
+ }
45
+ await this.publicWait;
46
+ this.check(screen);
47
+ if (!this.runtime.settings.settings.desktop?.publicAssistants?.includes(this.assistantId)) throw new Error("这个助手没有使用公共屏幕的权限。");
48
+ }
49
+ return screen;
50
+ }
51
+ private async getPage(screen: string): Promise<Page> {
52
+ this.check(screen);
53
+ let connection = this.connections.get(screen);
54
+ if (!connection) { connection = {}; this.connections.set(screen, connection); }
55
+ if (connection.page && !connection.page.isClosed() && connection.browser?.isConnected()) return connection.page;
56
+ if (!connection.opening) connection.opening = this.openPage(screen, connection).finally(() => { connection!.opening = undefined; });
57
+ return connection.opening;
58
+ }
59
+ private async openPage(screen: string, connection: PageConnection): Promise<Page> {
60
+ const result = await (await this.runtime.desktop.request(`/api/screens/${encodeURIComponent(screen)}/browser/start`, { method: "POST" })).json() as { cdp: string };
61
+ this.check(screen);
62
+ if (result.cdp !== `/cdp/${screen}`) throw new Error("云桌面返回了无效的浏览器地址。");
63
+ const { url, token } = this.runtime.desktop.connection();
64
+ const endpoint = new URL(result.cdp, url);
65
+ endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
66
+ const browser = await chromium.connectOverCDP(endpoint.toString(), { headers: { Authorization: `Bearer ${token}` }, timeout: 15_000 });
67
+ try {
68
+ this.check(screen);
69
+ const context = browser.contexts()[0];
70
+ if (!context) throw new Error("云桌面浏览器没有可用的窗口。");
71
+ const page = await context.newPage();
72
+ this.check(screen);
73
+ page.setDefaultTimeout(15_000);
74
+ connection.browser = browser;
75
+ connection.page = page;
76
+ return page;
77
+ } catch (error) { await browser.close().catch(() => undefined); throw error; }
78
+ }
79
+ async dispose() {
80
+ this.disposed = true;
81
+ this.runtime.desktopPublic.cancelWait(this);
82
+ await Promise.allSettled([...this.active]);
83
+ await Promise.all([...this.connections.values()].map(async (connection) => {
84
+ await connection.opening?.catch(() => undefined);
85
+ await connection.browser?.close().catch(() => undefined);
86
+ }));
87
+ this.runtime.desktopPublic.release(this);
88
+ this.connections.clear();
89
+ }
90
+ tools(): ToolDefinition<any, any>[] {
91
+ const allowPublic = this.runtime.settings.settings.desktop?.publicAssistants?.includes(this.assistantId) ?? false;
92
+ const make = (name: string, label: string, description: string, parameters: any, run: (params: any, screen: string) => Promise<string | Buffer>): ToolDefinition<any, any> => ({
93
+ name, label, description: `${description} ${LOGIN}`,
94
+ parameters: Type.Object({ ...parameters.properties, ...(allowPublic ? { screen: Type.Optional(Type.Union([Type.Literal("own"), Type.Literal("public")])) } : {}) }),
95
+ execute: async (_id, params) => {
96
+ const operation = (async () => {
97
+ try {
98
+ const values = (params ?? {}) as Record<string, unknown>;
99
+ const screen = await this.screen(values.screen);
100
+ const value = await run(values, screen);
101
+ if (Buffer.isBuffer(value)) {
102
+ if (!this.canSeeImages) return { content: [{ type: "text" as const, text: "当前模型不能看图,请使用页面快照或请用户查看云桌面。" }], details: undefined };
103
+ return { content: [{ type: "image" as const, data: value.toString("base64"), mimeType: "image/png" }], details: undefined };
104
+ }
105
+ return { content: [{ type: "text" as const, text: value }], details: undefined };
106
+ } catch (error) {
107
+ return { content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], details: undefined, isError: true };
108
+ }
109
+ })();
110
+ this.active.add(operation);
111
+ try { return await operation; } finally { this.active.delete(operation); }
112
+ },
113
+ });
114
+ return [
115
+ make("browser_open", "打开网页", "在助手的浏览器中打开 HTTP(S) 网页。", Type.Object({ url: Type.String() }), async ({ url }, screen) => {
116
+ const target = new URL(url);
117
+ if (target.protocol !== "http:" && target.protocol !== "https:") throw new Error("只支持 HTTP(S) 网页。");
118
+ const page = await this.getPage(screen); this.check(screen); await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 }); return `已打开 ${page.url()}`;
119
+ }),
120
+ make("browser_snapshot", "读取页面", "读取当前页面的无障碍快照;引用用于点击或输入。", Type.Object({}), async (_, screen) => { const page = await this.getPage(screen); this.check(screen); return page.ariaSnapshot({ mode: "ai" }); }),
121
+ make("browser_click", "点击网页", "点击最新页面快照中的引用。", Type.Object({ ref: Type.String() }), async ({ ref }, screen) => { const page = await this.getPage(screen); this.check(screen); await page.getByRef(ref).click(); return "已点击。"; }),
122
+ make("browser_type", "输入文字", "向最新页面快照中的引用输入文字。", Type.Object({ ref: Type.String(), text: Type.String(), submit: Type.Optional(Type.Boolean()) }), async ({ ref, text, submit }, screen) => { const page = await this.getPage(screen); this.check(screen); const target = page.getByRef(ref); await target.fill(text); if (submit) { this.check(screen); await target.press("Enter"); } return "已输入。"; }),
123
+ make("browser_press", "按键", "向当前网页发送按键,如 Enter、Control+L。", Type.Object({ key: Type.String() }), async ({ key }, screen) => { const page = await this.getPage(screen); this.check(screen); await page.keyboard.press(key); return "已按键。"; }),
124
+ make("browser_back", "返回网页", "返回浏览器上一页。", Type.Object({}), async (_, screen) => { const page = await this.getPage(screen); this.check(screen); await page.goBack({ waitUntil: "domcontentloaded" }); return `当前页面:${page.url()}`; }),
125
+ make("browser_screenshot", "网页截图", "截取当前浏览器页面。", Type.Object({}), async (_, screen) => { const page = await this.getPage(screen); this.check(screen); return page.screenshot(); }),
126
+ make("desktop_screenshot", "桌面截图", "截取整个云桌面。", Type.Object({}), async (_, screen) => {
127
+ await this.runtime.desktop.request(`/api/screens/${screen}/start`, { method: "POST" });
128
+ this.check(screen);
129
+ return Buffer.from(await (await this.runtime.desktop.request(`/api/screens/${screen}/screenshot`)).arrayBuffer());
130
+ }),
131
+ make("desktop_action", "操作桌面", "按顺序执行桌面动作:click、move、drag、type、key、scroll、wait。", Type.Object({ actions: Type.Array(action, { minItems: 1, maxItems: 100 }) }), async ({ actions }, screen) => {
132
+ await this.runtime.desktop.request(`/api/screens/${screen}/start`, { method: "POST" });
133
+ const health = await (await this.runtime.desktop.request("/api/health")).json() as { screens: { id: string; browserRunning: boolean }[] };
134
+ this.check(screen);
135
+ if (health.screens.some((item) => item.id === screen && item.browserRunning)) {
136
+ await this.runtime.desktop.request(`/api/screens/${screen}/browser/focus`, { method: "POST" });
137
+ }
138
+ this.check(screen);
139
+ await this.runtime.desktop.request(`/api/screens/${screen}/input`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ actions }), signal: AbortSignal.timeout(60_000) });
140
+ return "桌面操作已完成。";
141
+ }),
142
+ ];
143
+ }
144
+ }
145
+
146
+ /** 公共屏幕保留 public;无效或冲突的助手 id 用稳定哈希。 */
147
+ export function desktopProfile(assistantId: string): string {
148
+ return assistantId !== "public" && /^[a-z0-9-]{1,64}$/.test(assistantId) ? assistantId : `p-${createHash("sha256").update(assistantId).digest("hex").slice(0, 62)}`;
149
+ }
150
+
151
+ /** 在云桌面里打开(或提到最前)这个助手的浏览器:用户接管后自己登录网站用。 */
152
+ export async function openDesktopBrowser(runtime: HobRuntime, assistantId: string): Promise<void> {
153
+ const profile = encodeURIComponent(desktopProfile(assistantId));
154
+ await runtime.desktop.request(`/api/screens/${profile}/browser/start`, { method: "POST" });
155
+ await runtime.desktop.request(`/api/screens/${profile}/browser/focus`, { method: "POST" });
156
+ }
157
+
158
+ export async function availableDesktopTools(runtime: HobRuntime, profile: string, canSeeImages: boolean): Promise<DesktopToolSession | undefined> {
159
+ try { await runtime.desktop.request("/api/health"); return new DesktopToolSession(runtime, profile, canSeeImages); }
160
+ catch { return undefined; }
161
+ }
@@ -0,0 +1,76 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readSecrets } from "./secrets.ts";
3
+ import type { SettingsStore } from "./settings.ts";
4
+
5
+ export class DesktopService {
6
+ constructor(private home: string, private settings: SettingsStore, private env: NodeJS.ProcessEnv = process.env) {}
7
+
8
+ get deployed(): boolean {
9
+ return !!(this.env.HOB_DESKTOP_URL && this.deploymentToken());
10
+ }
11
+
12
+ token(): string {
13
+ return this.configuration().token;
14
+ }
15
+
16
+ private deploymentToken(): string {
17
+ const token = this.env.HOB_DESKTOP_TOKEN;
18
+ if (token) return token;
19
+ const file = this.env.HOB_DESKTOP_TOKEN_FILE;
20
+ if (!file) return "";
21
+ try { return readFileSync(file, "utf8").trim(); } catch { return ""; }
22
+ }
23
+
24
+ private configuration() {
25
+ const saved = this.settings.settings.desktop ?? { url: "", enabled: false, publicAssistants: [] };
26
+ const deploymentToken = this.env.HOB_DESKTOP_URL ? this.deploymentToken() : "";
27
+ const deployed = !!deploymentToken;
28
+ const token = deployed ? deploymentToken : readSecrets(this.home)["desktop:token"] ?? "";
29
+ return {
30
+ revision: this.settings.get().revision,
31
+ url: deployed ? this.env.HOB_DESKTOP_URL! : saved.url,
32
+ enabled: deployed ? true : saved.enabled,
33
+ deployed,
34
+ tokenConfigured: !!token,
35
+ publicAssistants: saved.publicAssistants ?? [],
36
+ token,
37
+ };
38
+ }
39
+
40
+ view() {
41
+ const { token: _token, ...view } = this.configuration();
42
+ return view;
43
+ }
44
+
45
+ connection() {
46
+ const { enabled, url, token } = this.configuration();
47
+ if (!enabled || !url || !token) throw new Error("云桌面未配置或尚未启用。");
48
+ return { url: checkDesktopUrl(url), token };
49
+ }
50
+
51
+ async request(path: string, init: RequestInit = {}): Promise<Response> {
52
+ const { url, token } = this.connection();
53
+ const response = await fetch(new URL(path, url), {
54
+ ...init,
55
+ headers: { ...init.headers, Authorization: `Bearer ${token}` },
56
+ signal: init.signal ?? AbortSignal.timeout(10_000),
57
+ redirect: "error",
58
+ });
59
+ if (!response.ok) {
60
+ const body = await response.json().catch(() => null) as { error?: { message?: unknown } } | null;
61
+ const detail = typeof body?.error?.message === "string" ? body.error.message.replaceAll(token, "[已隐藏]") : "";
62
+ throw new Error(`云桌面请求失败(HTTP ${response.status})${detail ? `:${detail}` : "。"}`);
63
+ }
64
+ return response;
65
+ }
66
+ }
67
+
68
+ export function checkDesktopUrl(value: string): string {
69
+ if (!value.trim()) return "";
70
+ let url: URL;
71
+ try { url = new URL(value); } catch { throw new Error("云桌面地址无效。"); }
72
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
73
+ throw new Error("云桌面地址须为不含凭据或路径的 HTTP(S) 地址。");
74
+ }
75
+ return url.origin;
76
+ }
@@ -0,0 +1,52 @@
1
+ import type { EmbeddingSettings } from "../../shared/types.ts";
2
+ import { readSecrets } from "./secrets.ts";
3
+
4
+ export const EMBEDDING_KEY_SECRET = "embedding";
5
+
6
+ export function embeddingKey(home: string): string {
7
+ return readSecrets(home)[EMBEDDING_KEY_SECRET]?.trim() ?? "";
8
+ }
9
+
10
+ export function checkEmbeddingSettings(raw: unknown): EmbeddingSettings {
11
+ const input = (raw ?? {}) as Record<string, unknown>;
12
+ if (input.source === "off") return { source: "off" };
13
+ if (input.source !== "openai") throw new Error("向量模型来源无效。");
14
+ const baseUrl = typeof input.baseUrl === "string" ? input.baseUrl.trim().replace(/\/+$/, "") : "";
15
+ let url: URL;
16
+ try { url = new URL(baseUrl); } catch { throw new Error("向量服务地址无效,请填写 http:// 或 https:// 地址。"); }
17
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("向量服务地址仅支持 http:// 或 https://。");
18
+ const model = typeof input.model === "string" ? input.model.trim() : "";
19
+ if (!model) throw new Error("请填写向量模型名称。");
20
+ return { source: "openai", baseUrl, model };
21
+ }
22
+
23
+ export function embeddingModel(config: EmbeddingSettings): string {
24
+ return JSON.stringify(config.source === "openai" ? { source: config.source, baseUrl: config.baseUrl, model: config.model } : config);
25
+ }
26
+
27
+ export function normalizeEmbeddingSettings(raw: unknown, fallback: EmbeddingSettings): EmbeddingSettings {
28
+ try { return checkEmbeddingSettings(raw); } catch { return fallback; }
29
+ }
30
+
31
+ export async function embed(config: EmbeddingSettings, key: string, input: string[], fetchImpl: typeof fetch = fetch): Promise<number[][]> {
32
+ if (config.source !== "openai") throw new Error("尚未配置向量模型。");
33
+ if (!key.trim()) throw new Error("尚未填写向量模型 API Key。");
34
+ const vectors: number[][] = [];
35
+ for (let start = 0; start < input.length; start += 32) {
36
+ const batch = input.slice(start, start + 32);
37
+ const response = await fetchImpl(`${config.baseUrl}/embeddings`, {
38
+ method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
39
+ body: JSON.stringify({ model: config.model, input: batch }), signal: AbortSignal.timeout(30_000),
40
+ });
41
+ if (!response.ok) throw new Error(`向量接口返回 ${response.status}:${(await response.text()).slice(0, 1000)}`);
42
+ const result = await response.json() as { data?: { index: number; embedding: unknown }[] };
43
+ if (!Array.isArray(result.data) || result.data.length !== batch.length) throw new Error("向量接口返回的数量不正确。");
44
+ const sorted = [...result.data].sort((a, b) => a.index - b.index);
45
+ for (const [i, item] of sorted.entries()) {
46
+ if (item.index !== i || !Array.isArray(item.embedding) || !item.embedding.length || !item.embedding.every((v: unknown) => typeof v === "number" && Number.isFinite(v))) throw new Error("向量接口返回的数据无效。");
47
+ vectors.push(item.embedding as number[]);
48
+ }
49
+ }
50
+ if (vectors.some((v) => v.length !== vectors[0]?.length)) throw new Error("向量接口返回的维数不一致。");
51
+ return vectors;
52
+ }