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.
Files changed (82) hide show
  1. package/README.md +76 -6
  2. package/README.zh-CN.md +76 -6
  3. package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
  4. package/dist/web/assets/index-Di5lyTHO.css +1 -0
  5. package/dist/web/assets/index-y09i1RGd.js +267 -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/package.json +7 -1
  13. package/server/src/actions.ts +16 -56
  14. package/server/src/assistant-skills.ts +215 -0
  15. package/server/src/boot.ts +1 -0
  16. package/server/src/claude-status.ts +47 -0
  17. package/server/src/config.ts +3 -1
  18. package/server/src/delegations.ts +21 -11
  19. package/server/src/desktop-control.ts +152 -0
  20. package/server/src/desktop-events.ts +63 -0
  21. package/server/src/desktop-public.ts +43 -0
  22. package/server/src/desktop-tools.ts +161 -0
  23. package/server/src/desktop.ts +76 -0
  24. package/server/src/embedding.ts +52 -0
  25. package/server/src/files.ts +63 -43
  26. package/server/src/http.ts +325 -13
  27. package/server/src/job-view.ts +52 -0
  28. package/server/src/jobs.ts +326 -44
  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 +4 -4
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +51 -156
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +39 -5
  43. package/server/src/router-activity.ts +6 -0
  44. package/server/src/router-tools.ts +184 -39
  45. package/server/src/router.ts +154 -17
  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/transcript.ts +57 -4
  51. package/server/src/worker-tools.ts +14 -12
  52. package/shared/knowledge.ts +92 -0
  53. package/shared/mcp.ts +91 -0
  54. package/shared/skills.ts +18 -0
  55. package/shared/types.ts +104 -20
  56. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  57. package/vendor/claude-bridge/LICENSE +21 -0
  58. package/vendor/claude-bridge/package.json +62 -0
  59. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  60. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  61. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  62. package/vendor/claude-bridge/src/attachments.ts +135 -0
  63. package/vendor/claude-bridge/src/config.ts +105 -0
  64. package/vendor/claude-bridge/src/convert.ts +222 -0
  65. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  66. package/vendor/claude-bridge/src/index.ts +2503 -0
  67. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  68. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  69. package/vendor/claude-bridge/src/models.ts +164 -0
  70. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  71. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  72. package/vendor/claude-bridge/src/query-state.ts +118 -0
  73. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  74. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  75. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  76. package/vendor/claude-bridge/src/skills.ts +20 -0
  77. package/vendor/claude-bridge/src/transcript.ts +86 -0
  78. package/vendor/claude-bridge/src/usage.ts +53 -0
  79. package/vendor/lore-pi-extension/package.json +1 -1
  80. package/vendor/lore-pi-extension/tools.ts +8 -5
  81. package/dist/web/assets/index-BCGEZvgg.css +0 -1
  82. package/dist/web/assets/index-BqkIAlk9.js +0 -258
@@ -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
+ }