@greatlhd/ailo-desktop 1.0.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/copy-static.mjs +11 -0
- package/dist/browser_control.js +767 -0
- package/dist/browser_snapshot.js +174 -0
- package/dist/cli.js +36 -0
- package/dist/code_executor.js +95 -0
- package/dist/config_server.js +658 -0
- package/dist/connection_util.js +14 -0
- package/dist/constants.js +2 -0
- package/dist/desktop_state_store.js +57 -0
- package/dist/desktop_types.js +1 -0
- package/dist/desktop_verifier.js +40 -0
- package/dist/dingtalk-handler.js +173 -0
- package/dist/dingtalk-types.js +1 -0
- package/dist/email_handler.js +501 -0
- package/dist/exec_tool.js +90 -0
- package/dist/feishu-handler.js +620 -0
- package/dist/feishu-types.js +8 -0
- package/dist/feishu-utils.js +162 -0
- package/dist/fs_tools.js +398 -0
- package/dist/index.js +433 -0
- package/dist/mcp/config-manager.js +64 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp/rpc.js +109 -0
- package/dist/mcp/session.js +140 -0
- package/dist/mcp_manager.js +253 -0
- package/dist/mouse_keyboard.js +516 -0
- package/dist/qq-handler.js +153 -0
- package/dist/qq-types.js +15 -0
- package/dist/qq-ws.js +178 -0
- package/dist/screenshot.js +271 -0
- package/dist/skills_hub.js +212 -0
- package/dist/skills_manager.js +103 -0
- package/dist/static/AGENTS.md +25 -0
- package/dist/static/app.css +539 -0
- package/dist/static/app.html +292 -0
- package/dist/static/app.js +380 -0
- package/dist/static/chat.html +994 -0
- package/dist/time_tool.js +22 -0
- package/dist/utils.js +15 -0
- package/package.json +38 -0
- package/src/browser_control.ts +739 -0
- package/src/browser_snapshot.ts +196 -0
- package/src/cli.ts +44 -0
- package/src/code_executor.ts +101 -0
- package/src/config_server.ts +723 -0
- package/src/connection_util.ts +23 -0
- package/src/constants.ts +2 -0
- package/src/desktop_state_store.ts +64 -0
- package/src/desktop_types.ts +44 -0
- package/src/desktop_verifier.ts +45 -0
- package/src/dingtalk-types.ts +26 -0
- package/src/exec_tool.ts +93 -0
- package/src/feishu-handler.ts +722 -0
- package/src/feishu-types.ts +66 -0
- package/src/feishu-utils.ts +174 -0
- package/src/fs_tools.ts +411 -0
- package/src/index.ts +474 -0
- package/src/mcp/config-manager.ts +85 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/rpc.ts +131 -0
- package/src/mcp/session.ts +182 -0
- package/src/mcp_manager.ts +273 -0
- package/src/mouse_keyboard.ts +526 -0
- package/src/qq-types.ts +49 -0
- package/src/qq-ws.ts +223 -0
- package/src/screenshot.ts +297 -0
- package/src/static/app.css +539 -0
- package/src/static/app.html +292 -0
- package/src/static/app.js +380 -0
- package/src/static/chat.html +994 -0
- package/src/time_tool.ts +24 -0
- package/src/utils.ts +22 -0
- package/tsconfig.json +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ailo Desktop — 超级端点
|
|
4
|
+
*
|
|
5
|
+
* 集成桌面能力 + 飞书 + 钉钉 + QQ + 邮件。
|
|
6
|
+
* 各平台按需配置:有完整配置才启动对应 handler 并上报对应 blueprint。
|
|
7
|
+
* 未配置的平台不会被 Ailo 感知,不影响其他能力正常工作。
|
|
8
|
+
*
|
|
9
|
+
* 子命令:ailo-desktop init [--defaults] — 初始化 config.json 与 Skills 目录(见 cli.ts)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const [, , subcommand] = process.argv;
|
|
13
|
+
if (subcommand === "init") {
|
|
14
|
+
import("./cli.js")
|
|
15
|
+
.then(({ runInit }) =>
|
|
16
|
+
runInit(process.argv.includes("--defaults"))
|
|
17
|
+
.then(() => process.exit(0))
|
|
18
|
+
.catch((e) => {
|
|
19
|
+
console.error(e);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
.catch((e) => {
|
|
24
|
+
console.error("init 加载失败:", e);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
import { runEndpoint, type EndpointContext } from "@greatlhd/ailo-endpoint-sdk";
|
|
30
|
+
import type { ContentPart, ToolCapability } from "@greatlhd/ailo-endpoint-sdk";
|
|
31
|
+
import { inferMime, classifyMedia, mediaPart } from "@greatlhd/ailo-endpoint-sdk";
|
|
32
|
+
import { createInterface } from "readline";
|
|
33
|
+
import { dirname, join } from "path";
|
|
34
|
+
import { fileURLToPath } from "url";
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
|
|
38
|
+
async function promptPort(): Promise<number> {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const n = await new Promise<number | null>((resolve) => {
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
rl.question("请输入配置界面端口号: ", (answer) => {
|
|
43
|
+
rl.close();
|
|
44
|
+
const x = Number(answer.trim());
|
|
45
|
+
resolve(!Number.isNaN(x) && x > 0 && x < 65536 ? x : null);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
if (n !== null) return n;
|
|
49
|
+
console.error("无效端口,请输入 1-65535 之间的数字");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
import { DesktopStateStore } from "./desktop_state_store.js";
|
|
54
|
+
import { captureDesktopObservation } from "./screenshot.js";
|
|
55
|
+
import { execTool } from "./exec_tool.js";
|
|
56
|
+
import { fsTool } from "./fs_tools.js";
|
|
57
|
+
import { LocalMCPManager } from "./mcp_manager.js";
|
|
58
|
+
import { browserUse, stopBrowser } from "./browser_control.js";
|
|
59
|
+
import { executeCode } from "./code_executor.js";
|
|
60
|
+
import { startConfigServer } from "./config_server.js";
|
|
61
|
+
import { mouseKeyboard } from "./mouse_keyboard.js";
|
|
62
|
+
import { FeishuHandler } from "./feishu-handler.js";
|
|
63
|
+
import {
|
|
64
|
+
loadConnectionConfig,
|
|
65
|
+
hasValidConfig,
|
|
66
|
+
backoffDelayMs,
|
|
67
|
+
readConfig,
|
|
68
|
+
type AiloConnectionConfig,
|
|
69
|
+
} from "./connection_util.js";
|
|
70
|
+
import { CONFIG_FILENAME, NO_SUBJECT } from "./constants.js";
|
|
71
|
+
import { errMsg } from "./utils.js";
|
|
72
|
+
|
|
73
|
+
const BLUEPRINTS_DIR = join(__dirname, "..", "..", "..", "blueprints");
|
|
74
|
+
|
|
75
|
+
function parseArgs(): { port?: number; blueprintUrl?: string } {
|
|
76
|
+
const args = process.argv.slice(2);
|
|
77
|
+
let port: number | undefined;
|
|
78
|
+
let blueprintUrl: string | undefined;
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
81
|
+
const p = Number(args[++i]);
|
|
82
|
+
if (!Number.isNaN(p) && p > 0) port = p;
|
|
83
|
+
} else if (args[i] === "--blueprint-url" && args[i + 1]) blueprintUrl = args[++i];
|
|
84
|
+
}
|
|
85
|
+
return { port, blueprintUrl };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { port: CLI_PORT, blueprintUrl: CLI_BLUEPRINT_URL } = parseArgs();
|
|
89
|
+
|
|
90
|
+
const BLUEPRINT_URL = CLI_BLUEPRINT_URL ?? join(BLUEPRINTS_DIR, "desktop-agent.blueprint.md");
|
|
91
|
+
const BLUEPRINT_WEBCHAT = join(BLUEPRINTS_DIR, "webchat.blueprint.md");
|
|
92
|
+
const BLUEPRINT_FEISHU = join(BLUEPRINTS_DIR, "feishu.blueprint.md");
|
|
93
|
+
|
|
94
|
+
// ──────────────────────────────────────────────────────────────
|
|
95
|
+
// 配置加载:各平台 + email
|
|
96
|
+
// ──────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface FeishuConfig { appId: string; appSecret: string }
|
|
99
|
+
|
|
100
|
+
function loadSectionConfig<T>(
|
|
101
|
+
configPath: string,
|
|
102
|
+
section: string,
|
|
103
|
+
requiredKeys: string[],
|
|
104
|
+
mapper: (raw: Record<string, unknown>) => T,
|
|
105
|
+
): T | null {
|
|
106
|
+
const raw = readConfig(configPath);
|
|
107
|
+
const s = (raw as Record<string, unknown>)[section] as Record<string, unknown> | undefined;
|
|
108
|
+
if (!s || requiredKeys.some((k) => !s[k])) return null;
|
|
109
|
+
return mapper(s);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
const loadFeishuConfig = (p: string) =>
|
|
114
|
+
loadSectionConfig<FeishuConfig>(p, "feishu", ["appId", "appSecret"], (f) => ({
|
|
115
|
+
appId: f.appId as string,
|
|
116
|
+
appSecret: f.appSecret as string,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// ──────────────────────────────────────────────────────────────
|
|
121
|
+
// 运行时状态
|
|
122
|
+
// ──────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const mcpManager = new LocalMCPManager();
|
|
125
|
+
let endpointCtx: EndpointContext | null = null;
|
|
126
|
+
let webchatApi: { recordAiloReply: (text: string, participantName: string) => boolean } | null = null;
|
|
127
|
+
let feishuHandler: FeishuHandler | null = null;
|
|
128
|
+
let lastMcpToolSnapshot: Map<string, ToolCapability> = new Map();
|
|
129
|
+
const desktopStateStore = new DesktopStateStore();
|
|
130
|
+
|
|
131
|
+
function computeToolDiff(oldTools: Map<string, ToolCapability>, newTools: ToolCapability[]): {
|
|
132
|
+
register: ToolCapability[];
|
|
133
|
+
unregister: string[];
|
|
134
|
+
} {
|
|
135
|
+
const newMap = new Map(newTools.map((t) => [t.name, t]));
|
|
136
|
+
const register: ToolCapability[] = [];
|
|
137
|
+
const unregister: string[] = [];
|
|
138
|
+
for (const [name] of oldTools) {
|
|
139
|
+
if (!newMap.has(name)) unregister.push(name);
|
|
140
|
+
}
|
|
141
|
+
for (const [name, tool] of newMap) {
|
|
142
|
+
if (!oldTools.has(name)) register.push(tool);
|
|
143
|
+
}
|
|
144
|
+
return { register, unregister };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function syncMcpToolsToServer(): Promise<void> {
|
|
148
|
+
if (!endpointCtx) return;
|
|
149
|
+
const currentTools = mcpManager.getAllPrivateTools();
|
|
150
|
+
const { register, unregister } = computeToolDiff(lastMcpToolSnapshot, currentTools);
|
|
151
|
+
if (register.length === 0 && unregister.length === 0) return;
|
|
152
|
+
try {
|
|
153
|
+
await endpointCtx.update({
|
|
154
|
+
register: register.length > 0 ? { tools: register } : undefined,
|
|
155
|
+
unregister: unregister.length > 0 ? { tools: unregister } : undefined,
|
|
156
|
+
});
|
|
157
|
+
lastMcpToolSnapshot = new Map(currentTools.map((t) => [t.name, t]));
|
|
158
|
+
console.log(`[desktop] MCP 工具增量同步: +${register.length} -${unregister.length}`);
|
|
159
|
+
} catch (e: unknown) {
|
|
160
|
+
console.error("[desktop] MCP 工具增量同步失败:", errMsg(e));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function initSubsystems(): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
await mcpManager.init();
|
|
167
|
+
mcpManager.startWatching();
|
|
168
|
+
let mcpSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
169
|
+
mcpManager.setOnToolsChanged(() => {
|
|
170
|
+
if (mcpSyncTimer) return; // 防抖:500ms 内只同步一次
|
|
171
|
+
mcpSyncTimer = setTimeout(() => {
|
|
172
|
+
mcpSyncTimer = null;
|
|
173
|
+
console.log("[desktop] MCP 工具变更,增量同步到 Ailo");
|
|
174
|
+
syncMcpToolsToServer();
|
|
175
|
+
}, 500);
|
|
176
|
+
});
|
|
177
|
+
} catch (e: unknown) {
|
|
178
|
+
console.error("[desktop] MCP 初始化失败:", errMsg(e));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ──────────────────────────────────────────────────────────────
|
|
183
|
+
// 平台 handler 生命周期
|
|
184
|
+
// ──────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function startPlatformHandlers(ctx: EndpointContext, configPath: string): Promise<void> {
|
|
187
|
+
// 飞书
|
|
188
|
+
const feishuCfg = loadFeishuConfig(configPath);
|
|
189
|
+
if (feishuCfg) {
|
|
190
|
+
feishuHandler = new FeishuHandler(feishuCfg);
|
|
191
|
+
await feishuHandler.start(ctx);
|
|
192
|
+
console.log("[desktop] 飞书通道已启动");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function stopPlatformHandlers(): Promise<void> {
|
|
197
|
+
if (feishuHandler) {
|
|
198
|
+
await feishuHandler.stop();
|
|
199
|
+
feishuHandler = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ──────────────────────────────────────────────────────────────
|
|
204
|
+
// 动态计算 blueprints(只注册已配置的平台)
|
|
205
|
+
// ──────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function buildBlueprints(configPath: string): string[] {
|
|
208
|
+
const list: string[] = [BLUEPRINT_URL, BLUEPRINT_WEBCHAT];
|
|
209
|
+
if (loadFeishuConfig(configPath)) list.push(BLUEPRINT_FEISHU);
|
|
210
|
+
return list;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ──────────────────────────────────────────────────────────────
|
|
214
|
+
// 工具 handlers
|
|
215
|
+
// ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const FS_TOOLS = [
|
|
218
|
+
"read_file", "write_file", "edit_file", "append_file", "list_directory",
|
|
219
|
+
"find_files", "search_content", "delete_file", "move_file", "copy_file",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
function requireHandler<T>(handler: T | null, platform: string): T {
|
|
223
|
+
if (!handler) throw new Error(`${platform}未配置,请在配置页对应标签中填写信息`);
|
|
224
|
+
return handler;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const requireFeishu = () => requireHandler(feishuHandler, "飞书");
|
|
228
|
+
|
|
229
|
+
function buildToolHandlers(): Record<string, (args: Record<string, unknown>) => Promise<ContentPart[] | unknown>> {
|
|
230
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<ContentPart[] | unknown>> = {
|
|
231
|
+
screenshot: async (args) => {
|
|
232
|
+
const result = await captureDesktopObservation({
|
|
233
|
+
capture_window: !!args.capture_window,
|
|
234
|
+
screen: args.screen as number | "all" | undefined,
|
|
235
|
+
});
|
|
236
|
+
if (result.observation) desktopStateStore.saveObservation(result.observation);
|
|
237
|
+
return result.parts;
|
|
238
|
+
},
|
|
239
|
+
browser_use: async (args) => browserUse(args),
|
|
240
|
+
execute_code: async (args) => {
|
|
241
|
+
if (!endpointCtx) throw new Error("端点未就绪");
|
|
242
|
+
return executeCode(endpointCtx, args);
|
|
243
|
+
},
|
|
244
|
+
exec: async (args) => {
|
|
245
|
+
if (!endpointCtx) throw new Error("端点未就绪");
|
|
246
|
+
return execTool(endpointCtx, args);
|
|
247
|
+
},
|
|
248
|
+
mouse_keyboard: async (args) => mouseKeyboard(args, { stateStore: desktopStateStore }),
|
|
249
|
+
mcp_manage: async (args) => {
|
|
250
|
+
const result = await mcpManager.handle(args);
|
|
251
|
+
if (result.toolsChanged) syncMcpToolsToServer();
|
|
252
|
+
return result.text;
|
|
253
|
+
},
|
|
254
|
+
// 网页聊天发送
|
|
255
|
+
webchat_send: async (args) => {
|
|
256
|
+
const text = args.text as string | undefined;
|
|
257
|
+
const participantName = args.participantName as string;
|
|
258
|
+
const attachments = (args.attachments as { path?: string }[]) ?? [];
|
|
259
|
+
if (!webchatApi && attachments.length === 0) {
|
|
260
|
+
return [{ type: "text", text: JSON.stringify({ ok: false, error: "webchat未就绪,请先连接Ailo并打开配置页" }) }];
|
|
261
|
+
}
|
|
262
|
+
const results: string[] = [];
|
|
263
|
+
// 发送文字
|
|
264
|
+
if (text && webchatApi) {
|
|
265
|
+
const ok = webchatApi.recordAiloReply(text, participantName ?? "");
|
|
266
|
+
results.push(ok ? "文字已发送" : "文字发送失败,请确认participantName与网页聊天中的称呼一致且用户在线");
|
|
267
|
+
}
|
|
268
|
+
// 发送附件
|
|
269
|
+
if (attachments.length > 0) {
|
|
270
|
+
if (!endpointCtx) return [{ type: "text", text: JSON.stringify({ ok: false, error: "端点未就绪,无法发送附件" }) }];
|
|
271
|
+
for (const att of attachments) {
|
|
272
|
+
if (att.path) {
|
|
273
|
+
await endpointCtx.sendFile(att.path);
|
|
274
|
+
results.push(`文件已发送:${att.path}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (results.length === 0) return [{ type: "text", text: JSON.stringify({ ok: false, error: "请提供text或attachments" }) }];
|
|
279
|
+
return [{ type: "text", text: JSON.stringify({ ok: true, results }) }];
|
|
280
|
+
},
|
|
281
|
+
// 飞书发送
|
|
282
|
+
feishu_send: async (args) => {
|
|
283
|
+
const h = requireFeishu();
|
|
284
|
+
const atts = ((args.attachments as any[]) ?? []).map((a: any) => ({
|
|
285
|
+
type: a.type,
|
|
286
|
+
file_path: a.path,
|
|
287
|
+
mime: a.mime,
|
|
288
|
+
name: a.name,
|
|
289
|
+
duration: a.duration,
|
|
290
|
+
}));
|
|
291
|
+
await h.sendText(args.chat_id as string, (args.text as string) ?? "", atts);
|
|
292
|
+
return [{ type: "text", text: `已发送到 ${args.chat_id}` }];
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
for (const name of FS_TOOLS) {
|
|
296
|
+
handlers[name] = async (args) => fsTool(name, args);
|
|
297
|
+
}
|
|
298
|
+
return handlers;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ──────────────────────────────────────────────────────────────
|
|
302
|
+
// 主函数
|
|
303
|
+
// ──────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
async function main(): Promise<void> {
|
|
306
|
+
const port = CLI_PORT ?? (await promptPort());
|
|
307
|
+
const configPath = join(process.cwd(), CONFIG_FILENAME);
|
|
308
|
+
const connectionState = { connected: false, endpointId: "" };
|
|
309
|
+
let webchatCtxRef: EndpointContext | null = null;
|
|
310
|
+
let connectAttempt = 0;
|
|
311
|
+
let endpointConnecting = false;
|
|
312
|
+
|
|
313
|
+
async function reloadPlatformHandler<
|
|
314
|
+
C,
|
|
315
|
+
H extends { start(ctx: EndpointContext): Promise<void>; stop(): Promise<void> },
|
|
316
|
+
>(opts: {
|
|
317
|
+
getHandler: () => H | null;
|
|
318
|
+
setHandler: (h: H | null) => void;
|
|
319
|
+
loadConfig: () => C | null;
|
|
320
|
+
createHandler: (cfg: C) => H;
|
|
321
|
+
blueprint: string;
|
|
322
|
+
}): Promise<void> {
|
|
323
|
+
if (!endpointCtx) return;
|
|
324
|
+
const wasRunning = !!opts.getHandler();
|
|
325
|
+
if (opts.getHandler()) {
|
|
326
|
+
await opts.getHandler()!.stop();
|
|
327
|
+
opts.setHandler(null);
|
|
328
|
+
}
|
|
329
|
+
const cfg = opts.loadConfig();
|
|
330
|
+
if (cfg) {
|
|
331
|
+
const h = opts.createHandler(cfg);
|
|
332
|
+
opts.setHandler(h);
|
|
333
|
+
await h.start(endpointCtx);
|
|
334
|
+
if (!wasRunning) await endpointCtx.update({ register: { blueprints: [opts.blueprint] } });
|
|
335
|
+
} else if (wasRunning) {
|
|
336
|
+
await endpointCtx.update({ unregister: { blueprints: [opts.blueprint] } });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function applyConnectionConfig(overrides?: AiloConnectionConfig): Promise<void> {
|
|
341
|
+
const cfg = overrides ?? loadConnectionConfig(configPath);
|
|
342
|
+
if (!hasValidConfig(cfg)) return;
|
|
343
|
+
if (!endpointCtx && endpointConnecting) return;
|
|
344
|
+
|
|
345
|
+
endpointConnecting = true;
|
|
346
|
+
connectAttempt = 0;
|
|
347
|
+
try {
|
|
348
|
+
const blueprints = buildBlueprints(configPath);
|
|
349
|
+
runEndpoint({
|
|
350
|
+
ailoWsUrl: cfg.url,
|
|
351
|
+
ailoApiKey: cfg.apiKey,
|
|
352
|
+
endpointId: cfg.endpointId,
|
|
353
|
+
handler: {
|
|
354
|
+
start: async (ctx) => {
|
|
355
|
+
endpointCtx = ctx;
|
|
356
|
+
endpointConnecting = false;
|
|
357
|
+
connectAttempt = 0;
|
|
358
|
+
connectionState.connected = true;
|
|
359
|
+
connectionState.endpointId = cfg.endpointId;
|
|
360
|
+
webchatCtxRef = ctx;
|
|
361
|
+
lastMcpToolSnapshot = new Map(mcpManager.getAllPrivateTools().map((t) => [t.name, t]));
|
|
362
|
+
serverRef.notifyContextAttached();
|
|
363
|
+
await startPlatformHandlers(ctx, configPath);
|
|
364
|
+
console.log("[desktop] 桌面端点已启动");
|
|
365
|
+
},
|
|
366
|
+
stop: async () => {
|
|
367
|
+
await stopPlatformHandlers();
|
|
368
|
+
endpointCtx = null;
|
|
369
|
+
endpointConnecting = false;
|
|
370
|
+
connectionState.connected = false;
|
|
371
|
+
connectionState.endpointId = "";
|
|
372
|
+
webchatCtxRef = null;
|
|
373
|
+
await mcpManager.shutdown();
|
|
374
|
+
await stopBrowser();
|
|
375
|
+
console.log("[desktop] 桌面端点已停止");
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
caps: ["message", "tool_execute"],
|
|
379
|
+
blueprints,
|
|
380
|
+
tools: mcpManager.getAllPrivateTools(),
|
|
381
|
+
toolHandlers: buildToolHandlers(),
|
|
382
|
+
onUnknownTool: async (name: string, args: Record<string, unknown>) => {
|
|
383
|
+
const idx = name.indexOf(":");
|
|
384
|
+
if (idx > 0) {
|
|
385
|
+
const serverName = name.slice(0, idx);
|
|
386
|
+
const toolName = name.slice(idx + 1);
|
|
387
|
+
if (mcpManager.isRunning(serverName)) {
|
|
388
|
+
return mcpManager.executeToolCall(serverName, toolName, args);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`unknown tool: ${name}`);
|
|
392
|
+
},
|
|
393
|
+
onConnectFailure: async (err, client) => {
|
|
394
|
+
const delay = backoffDelayMs(connectAttempt++);
|
|
395
|
+
console.error(`[desktop] 连接失败,${(delay / 1000).toFixed(1)}s 后使用最新配置重试 (${err.message})`);
|
|
396
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
397
|
+
const latest = loadConnectionConfig(configPath);
|
|
398
|
+
if (!hasValidConfig(latest)) {
|
|
399
|
+
endpointConnecting = false;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await client.reconnect(undefined, {
|
|
404
|
+
url: latest.url,
|
|
405
|
+
apiKey: latest.apiKey,
|
|
406
|
+
endpointId: latest.endpointId,
|
|
407
|
+
});
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.error("[desktop] 重试连接失败:", errMsg(e));
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
} catch (e) {
|
|
414
|
+
endpointConnecting = false;
|
|
415
|
+
throw e;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await initSubsystems();
|
|
420
|
+
|
|
421
|
+
const serverRef = startConfigServer({
|
|
422
|
+
mcpManager,
|
|
423
|
+
getConnectionStatus: () => connectionState,
|
|
424
|
+
getWebchatCtx: () => webchatCtxRef,
|
|
425
|
+
port,
|
|
426
|
+
configPath,
|
|
427
|
+
blueprintUrl: BLUEPRINT_URL,
|
|
428
|
+
blueprintLocalPath: join(BLUEPRINTS_DIR, "desktop-agent.blueprint.md"),
|
|
429
|
+
getBlueprintPaths: () => buildBlueprints(configPath),
|
|
430
|
+
onWebchatReady: (api) => { webchatApi = api; },
|
|
431
|
+
onRequestReconnect: async () => {
|
|
432
|
+
if (!endpointCtx) return;
|
|
433
|
+
await endpointCtx.client.reconnect([]);
|
|
434
|
+
},
|
|
435
|
+
onConnectionConfigSaved: async (config) => {
|
|
436
|
+
const cfg: AiloConnectionConfig = {
|
|
437
|
+
url: config.ailoWsUrl,
|
|
438
|
+
apiKey: config.ailoApiKey,
|
|
439
|
+
endpointId: config.endpointId,
|
|
440
|
+
};
|
|
441
|
+
if (endpointCtx) {
|
|
442
|
+
await endpointCtx.client.reconnect(undefined, cfg);
|
|
443
|
+
} else {
|
|
444
|
+
await applyConnectionConfig(cfg);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
onFeishuConfigSaved: () => reloadPlatformHandler({
|
|
448
|
+
getHandler: () => feishuHandler,
|
|
449
|
+
setHandler: (h) => { feishuHandler = h; },
|
|
450
|
+
loadConfig: () => loadFeishuConfig(configPath),
|
|
451
|
+
createHandler: (cfg) => new FeishuHandler(cfg),
|
|
452
|
+
blueprint: BLUEPRINT_FEISHU,
|
|
453
|
+
}),
|
|
454
|
+
getFeishuStatus: () => ({
|
|
455
|
+
configured: !!loadFeishuConfig(configPath),
|
|
456
|
+
running: !!feishuHandler,
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const initial = loadConnectionConfig(configPath);
|
|
461
|
+
if (hasValidConfig(initial)) {
|
|
462
|
+
await applyConnectionConfig(initial);
|
|
463
|
+
} else {
|
|
464
|
+
console.log("[desktop] 未检测到 Ailo 连接配置,请在配置页填写并保存,将自动尝试连接(连不上会退避重试)。");
|
|
465
|
+
console.log(`[desktop] 配置界面: http://127.0.0.1:${port}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (subcommand !== "init") {
|
|
470
|
+
main().catch((e) => {
|
|
471
|
+
console.error(e);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, stat } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
|
|
6
|
+
export interface MCPServerConfig {
|
|
7
|
+
transport?: "stdio" | "sse";
|
|
8
|
+
command?: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
url?: string;
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MCPConfigFile {
|
|
16
|
+
mcpServers: Record<string, MCPServerConfig>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CONFIG_DIR = join(homedir(), ".agents");
|
|
20
|
+
const CONFIG_PATH = join(CONFIG_DIR, "mcp_config.json");
|
|
21
|
+
|
|
22
|
+
export class MCPConfigManager {
|
|
23
|
+
private configs = new Map<string, MCPServerConfig>();
|
|
24
|
+
private lastMtime = 0;
|
|
25
|
+
|
|
26
|
+
getConfigPath(): string {
|
|
27
|
+
return CONFIG_PATH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async load(): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(CONFIG_PATH, "utf-8");
|
|
33
|
+
const data = JSON.parse(raw) as MCPConfigFile;
|
|
34
|
+
this.configs.clear();
|
|
35
|
+
for (const [name, cfg] of Object.entries(data.mcpServers ?? {})) {
|
|
36
|
+
this.configs.set(name, cfg);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const s = await stat(CONFIG_PATH);
|
|
40
|
+
this.lastMtime = s.mtimeMs;
|
|
41
|
+
} catch {}
|
|
42
|
+
} catch {
|
|
43
|
+
// no config file yet
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async save(): Promise<void> {
|
|
48
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
49
|
+
const data: MCPConfigFile = { mcpServers: Object.fromEntries(this.configs) };
|
|
50
|
+
await writeFile(CONFIG_PATH, JSON.stringify(data, null, 2), "utf-8");
|
|
51
|
+
try {
|
|
52
|
+
const s = await stat(CONFIG_PATH);
|
|
53
|
+
this.lastMtime = s.mtimeMs;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(name: string): MCPServerConfig | undefined {
|
|
58
|
+
return this.configs.get(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set(name: string, config: MCPServerConfig): void {
|
|
62
|
+
this.configs.set(name, config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
delete(name: string): boolean {
|
|
66
|
+
return this.configs.delete(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getAll(): Map<string, MCPServerConfig> {
|
|
70
|
+
return new Map(this.configs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getLastMtime(): number {
|
|
74
|
+
return this.lastMtime;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async checkChanged(): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
const s = await stat(CONFIG_PATH);
|
|
80
|
+
return s.mtimeMs > this.lastMtime;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { MCPConfigManager } from './config-manager.js';
|
|
2
|
+
export type { MCPServerConfig, MCPConfigFile } from './config-manager.js';
|
|
3
|
+
|
|
4
|
+
export { startStdioServer, startSSEServer, stopSession } from './session.js';
|
|
5
|
+
export type { MCPSession, StdioSession, SSESession, PendingRPC } from './session.js';
|
|
6
|
+
|
|
7
|
+
export { initializeSession, createStdioRpc, createSSERpc, handleSSEMessage } from './rpc.js';
|
package/src/mcp/rpc.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { MCPSession, StdioSession, SSESession, PendingRPC } from "./session.js";
|
|
2
|
+
import type { ToolCapability } from "@greatlhd/ailo-endpoint-sdk";
|
|
3
|
+
|
|
4
|
+
export async function initializeSession(
|
|
5
|
+
session: MCPSession,
|
|
6
|
+
request: (method: string, params: unknown) => Promise<unknown>,
|
|
7
|
+
notify: (method: string, params: unknown) => void,
|
|
8
|
+
): Promise<ToolCapability[]> {
|
|
9
|
+
await request("initialize", {
|
|
10
|
+
protocolVersion: "2024-11-05",
|
|
11
|
+
capabilities: {},
|
|
12
|
+
clientInfo: { name: "ailo-desktop", version: "1.0.0" },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
notify("notifications/initialized", {});
|
|
16
|
+
|
|
17
|
+
const result = await request("tools/list", {}) as { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> };
|
|
18
|
+
const tools: ToolCapability[] = (result.tools ?? []).map((t) => ({
|
|
19
|
+
name: t.name,
|
|
20
|
+
description: t.description,
|
|
21
|
+
parameters: t.inputSchema as Record<string, unknown>,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
return tools;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createStdioRpc(
|
|
28
|
+
session: StdioSession,
|
|
29
|
+
): {
|
|
30
|
+
request: (method: string, params: unknown) => Promise<unknown>;
|
|
31
|
+
notify: (method: string, params: unknown) => void;
|
|
32
|
+
processBuffer: () => void;
|
|
33
|
+
} {
|
|
34
|
+
const request = (method: string, params: unknown): Promise<unknown> => {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const id = session.nextId++;
|
|
37
|
+
session.pendingRequests.set(id, { resolve, reject });
|
|
38
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
39
|
+
session.proc.stdin?.write(msg + "\n");
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
if (session.pendingRequests.has(id)) {
|
|
42
|
+
session.pendingRequests.delete(id);
|
|
43
|
+
reject(new Error(`RPC timeout for ${method}`));
|
|
44
|
+
}
|
|
45
|
+
}, 30000);
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const notify = (method: string, params: unknown): void => {
|
|
50
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
51
|
+
session.proc.stdin?.write(msg + "\n");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const processBuffer = (): void => {
|
|
55
|
+
const lines = session.buffer.split("\n");
|
|
56
|
+
session.buffer = lines.pop() ?? "";
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed) continue;
|
|
60
|
+
try {
|
|
61
|
+
const msg = JSON.parse(trimmed);
|
|
62
|
+
if (msg.id !== undefined && session.pendingRequests.has(msg.id)) {
|
|
63
|
+
const pending = session.pendingRequests.get(msg.id)!;
|
|
64
|
+
session.pendingRequests.delete(msg.id);
|
|
65
|
+
if (msg.error) pending.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
|
|
66
|
+
else pending.resolve(msg.result);
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { request, notify, processBuffer };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createSSERpc(
|
|
76
|
+
session: SSESession,
|
|
77
|
+
): {
|
|
78
|
+
request: (method: string, params: unknown) => Promise<unknown>;
|
|
79
|
+
notify: (method: string, params: unknown) => void;
|
|
80
|
+
} {
|
|
81
|
+
const request = (method: string, params: unknown): Promise<unknown> => {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const id = session.nextId++;
|
|
84
|
+
session.pendingRequests.set(id, { resolve, reject });
|
|
85
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
86
|
+
fetch(session.messageEndpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: msg,
|
|
90
|
+
signal: session.abortController.signal,
|
|
91
|
+
}).catch((err) => {
|
|
92
|
+
if (session.pendingRequests.has(id)) {
|
|
93
|
+
session.pendingRequests.delete(id);
|
|
94
|
+
reject(new Error(`SSE POST failed: ${err.message}`));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
if (session.pendingRequests.has(id)) {
|
|
99
|
+
session.pendingRequests.delete(id);
|
|
100
|
+
reject(new Error(`RPC timeout for ${method}`));
|
|
101
|
+
}
|
|
102
|
+
}, 30000);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const notify = (method: string, params: unknown): void => {
|
|
107
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
108
|
+
fetch(session.messageEndpoint, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: msg,
|
|
112
|
+
signal: session.abortController.signal,
|
|
113
|
+
}).catch((err) => {
|
|
114
|
+
console.error(`[mcp:sse] notify POST failed:`, err.message);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return { request, notify };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function handleSSEMessage(session: SSESession, data: string): void {
|
|
122
|
+
try {
|
|
123
|
+
const msg = JSON.parse(data);
|
|
124
|
+
if (msg.id !== undefined && session.pendingRequests.has(msg.id)) {
|
|
125
|
+
const pending = session.pendingRequests.get(msg.id)!;
|
|
126
|
+
session.pendingRequests.delete(msg.id);
|
|
127
|
+
if (msg.error) pending.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
|
|
128
|
+
else pending.resolve(msg.result);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|