@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
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "http";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join, resolve, dirname, basename } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
7
|
+
import type { EndpointContext } from "@greatlhd/ailo-endpoint-sdk";
|
|
8
|
+
import type { AcceptMessage, ContextTag } from "@greatlhd/ailo-endpoint-sdk";
|
|
9
|
+
import { textPart, readConfig, writeConfig, getNestedValue, setNestedValue } from "@greatlhd/ailo-endpoint-sdk";
|
|
10
|
+
import type { LocalMCPManager } from "./mcp_manager.js";
|
|
11
|
+
import { errMsg } from "./utils.js";
|
|
12
|
+
|
|
13
|
+
/** 网页聊天消息内容项 */
|
|
14
|
+
export type WebchatContentItem =
|
|
15
|
+
| { kind: 'text'; text: string }
|
|
16
|
+
| { kind: 'image'; url: string; name?: string }
|
|
17
|
+
| { kind: 'file'; url: string; name?: string };
|
|
18
|
+
|
|
19
|
+
function resolveStaticFile(filename: string): string {
|
|
20
|
+
const base = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const inStatic = join(base, "static", filename);
|
|
22
|
+
if (existsSync(inStatic)) return inStatic;
|
|
23
|
+
return join(base, "..", "src", "static", filename);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 单项运行环境检测结果 */
|
|
27
|
+
export interface EnvRuntimeItem {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
detail?: string;
|
|
33
|
+
hint?: string;
|
|
34
|
+
canAutoInstall?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 运行环境检测结果(供 Skills / 代码执行等使用) */
|
|
38
|
+
export interface EnvCheckResult {
|
|
39
|
+
runtimes: EnvRuntimeItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ConfigServerDeps {
|
|
43
|
+
mcpManager: LocalMCPManager;
|
|
44
|
+
getConnectionStatus: () => { connected: boolean; endpointId: string };
|
|
45
|
+
port: number;
|
|
46
|
+
/** config.json path for Ailo connection config */
|
|
47
|
+
configPath?: string;
|
|
48
|
+
/** 蓝图的 URL(与上报给 Ailo 的 blueprints 一致),用于 GET /api/tools 解析内置工具 */
|
|
49
|
+
blueprintUrl?: string;
|
|
50
|
+
/** 远程蓝图 404 时的本地回退路径(绝对路径或相对 cwd) */
|
|
51
|
+
blueprintLocalPath?: string;
|
|
52
|
+
/** 当存在时启用网页聊天:同一端口提供 /chat 与 /chat/ws,并调用 onWebchatReady */
|
|
53
|
+
webchatCtx?: EndpointContext;
|
|
54
|
+
/** 动态获取网页聊天上下文(连接建立后可挂载,无需重启) */
|
|
55
|
+
getWebchatCtx?: () => EndpointContext | null;
|
|
56
|
+
/** 网页聊天就绪后回调,供 index 的 send 工具使用 */
|
|
57
|
+
onWebchatReady?: (api: { recordAiloReply: (text: string, participantName: string, content?: WebchatContentItem[]) => boolean }) => void;
|
|
58
|
+
/** 请求热重连以刷新服务端 Skills 列表(启用/禁用后调用,无需重启) */
|
|
59
|
+
onRequestReconnect?: () => Promise<void>;
|
|
60
|
+
/** 保存 Ailo 连接配置后调用,用于断线后使用新配置重连 */
|
|
61
|
+
onConnectionConfigSaved?: (config: { ailoWsUrl: string; ailoApiKey: string; endpointId: string }) => Promise<void>;
|
|
62
|
+
/** 邮件配置保存后回调,重建邮件通道 */
|
|
63
|
+
onEmailConfigSaved?: () => Promise<void>;
|
|
64
|
+
/** 获取邮件通道状态 */
|
|
65
|
+
getEmailStatus?: () => { configured: boolean; running: boolean };
|
|
66
|
+
/** 飞书配置保存后回调 */
|
|
67
|
+
onFeishuConfigSaved?: () => Promise<void>;
|
|
68
|
+
/** 获取飞书通道状态 */
|
|
69
|
+
getFeishuStatus?: () => { configured: boolean; running: boolean };
|
|
70
|
+
/** 钉钉配置保存后回调 */
|
|
71
|
+
onDingtalkConfigSaved?: () => Promise<void>;
|
|
72
|
+
/** 获取钉钉通道状态 */
|
|
73
|
+
getDingtalkStatus?: () => { configured: boolean; running: boolean };
|
|
74
|
+
/** QQ 配置保存后回调 */
|
|
75
|
+
onQQConfigSaved?: () => Promise<void>;
|
|
76
|
+
/** 获取 QQ 通道状态 */
|
|
77
|
+
getQQStatus?: () => { configured: boolean; running: boolean };
|
|
78
|
+
/** 动态返回当前所有已激活蓝图的本地路径(用于 /api/blueprints 多蓝图展示) */
|
|
79
|
+
getBlueprintPaths?: () => string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getChatHtmlPath(): string {
|
|
83
|
+
return resolveStaticFile("chat.html");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serveChatPage(res: ServerResponse, htmlPath: string): void {
|
|
87
|
+
try {
|
|
88
|
+
const html = readFileSync(htmlPath, "utf-8");
|
|
89
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
90
|
+
res.end(html);
|
|
91
|
+
} catch (e: unknown) {
|
|
92
|
+
res.writeHead(500);
|
|
93
|
+
res.end("Failed to load chat page: " + errMsg(e));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ConfigServerRef {
|
|
98
|
+
/** 连接建立后调用,使网页聊天与 onWebchatReady 生效 */
|
|
99
|
+
notifyContextAttached(): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function startConfigServer(deps: ConfigServerDeps): ConfigServerRef {
|
|
103
|
+
const chatHtmlPath = getChatHtmlPath();
|
|
104
|
+
const clientsByParticipant = new Map<string, Set<WebSocket>>();
|
|
105
|
+
const participantByClient = new Map<WebSocket, string>();
|
|
106
|
+
const getWebchatCtx = (): EndpointContext | null => deps.getWebchatCtx?.() ?? deps.webchatCtx ?? null;
|
|
107
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
108
|
+
|
|
109
|
+
const MAX_PENDING_PER_USER = 50;
|
|
110
|
+
const PENDING_TTL_MS = 5 * 60 * 1000;
|
|
111
|
+
const pendingMessages = new Map<string, { text: string; ts: number }[]>();
|
|
112
|
+
|
|
113
|
+
function normalizeParticipantName(participantName?: string): string {
|
|
114
|
+
return typeof participantName === "string" ? participantName.trim() : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function enqueuePending(routeName: string, text: string): void {
|
|
118
|
+
let queue = pendingMessages.get(routeName);
|
|
119
|
+
if (!queue) {
|
|
120
|
+
queue = [];
|
|
121
|
+
pendingMessages.set(routeName, queue);
|
|
122
|
+
}
|
|
123
|
+
queue.push({ text, ts: Date.now() });
|
|
124
|
+
if (queue.length > MAX_PENDING_PER_USER) queue.splice(0, queue.length - MAX_PENDING_PER_USER);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flushPending(routeName: string, ws: WebSocket): void {
|
|
128
|
+
const queue = pendingMessages.get(routeName);
|
|
129
|
+
if (!queue || queue.length === 0) return;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const valid = queue.filter((m) => now - m.ts < PENDING_TTL_MS);
|
|
132
|
+
pendingMessages.delete(routeName);
|
|
133
|
+
for (const m of valid) {
|
|
134
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "reply", text: m.text }));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bindClient(participantName: string, ws: WebSocket): void {
|
|
139
|
+
const previous = participantByClient.get(ws);
|
|
140
|
+
if (previous && previous !== participantName) {
|
|
141
|
+
const previousSet = clientsByParticipant.get(previous);
|
|
142
|
+
previousSet?.delete(ws);
|
|
143
|
+
if (previousSet && previousSet.size === 0) clientsByParticipant.delete(previous);
|
|
144
|
+
}
|
|
145
|
+
let group = clientsByParticipant.get(participantName);
|
|
146
|
+
if (!group) {
|
|
147
|
+
group = new Set<WebSocket>();
|
|
148
|
+
clientsByParticipant.set(participantName, group);
|
|
149
|
+
}
|
|
150
|
+
group.add(ws);
|
|
151
|
+
participantByClient.set(ws, participantName);
|
|
152
|
+
flushPending(participantName, ws);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function unbindClient(ws: WebSocket): void {
|
|
156
|
+
const name = participantByClient.get(ws);
|
|
157
|
+
if (!name) return;
|
|
158
|
+
participantByClient.delete(ws);
|
|
159
|
+
const group = clientsByParticipant.get(name);
|
|
160
|
+
group?.delete(ws);
|
|
161
|
+
if (group && group.size === 0) clientsByParticipant.delete(name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleRegister(participantName: string | undefined, ws: WebSocket): void {
|
|
165
|
+
const routeName = normalizeParticipantName(participantName);
|
|
166
|
+
if (!routeName) return;
|
|
167
|
+
bindClient(routeName, ws);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleChatMessage(text: string, participantName: string | undefined, ws: WebSocket): void {
|
|
171
|
+
const ctx = getWebchatCtx();
|
|
172
|
+
if (!ctx || !text?.trim()) return;
|
|
173
|
+
const routeName = normalizeParticipantName(participantName);
|
|
174
|
+
if (!routeName) {
|
|
175
|
+
ctx.log("warn", "Webchat 上行消息缺少 participantName,已拒绝");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
bindClient(routeName, ws);
|
|
179
|
+
const tags: ContextTag[] = [
|
|
180
|
+
{ kind: "channel", value: "web", groupWith: true },
|
|
181
|
+
{ kind: "conv_type", value: "私聊", groupWith: false },
|
|
182
|
+
{ kind: "chat_id", value: routeName, groupWith: true, passToTool: true },
|
|
183
|
+
];
|
|
184
|
+
const msg: AcceptMessage = { content: [textPart(text)], contextTags: tags };
|
|
185
|
+
ctx.accept(msg).catch((err: unknown) => {
|
|
186
|
+
getWebchatCtx()?.log("error", `Failed to send message to Ailo: ${err instanceof Error ? err.message : err}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function recordAiloReply(text: string, participantName: string): boolean {
|
|
191
|
+
const routeName = normalizeParticipantName(participantName);
|
|
192
|
+
if (!routeName) return false;
|
|
193
|
+
const group = clientsByParticipant.get(routeName);
|
|
194
|
+
if (!group || group.size === 0) {
|
|
195
|
+
enqueuePending(routeName, text);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const payload = JSON.stringify({ type: "reply", text });
|
|
199
|
+
let sent = 0;
|
|
200
|
+
for (const client of group) {
|
|
201
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
202
|
+
client.send(payload);
|
|
203
|
+
sent += 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (sent === 0) {
|
|
207
|
+
enqueuePending(routeName, text);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const server = createServer(async (req, res) => {
|
|
214
|
+
const url = new URL(req.url ?? "/", `http://localhost:${deps.port}`);
|
|
215
|
+
const path = url.pathname;
|
|
216
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
217
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
218
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
219
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (path === "/" || path === "/index.html") return serveUI(res, deps);
|
|
223
|
+
if (path === "/app.css" || path === "/app.js") return serveStatic(res, path.slice(1));
|
|
224
|
+
if (path === "/chat" && req.method === "GET") return serveChatPage(res, chatHtmlPath);
|
|
225
|
+
// Status
|
|
226
|
+
if (path === "/api/status") return json(res, deps.getConnectionStatus());
|
|
227
|
+
// 运行环境检测与一键安装(供 Skills、代码执行、浏览器等使用)
|
|
228
|
+
if (path === "/api/env/check" && req.method === "GET") return json(res, await getEnvCheck());
|
|
229
|
+
if (path === "/api/env/install" && req.method === "POST") return json(res, await runEnvInstall());
|
|
230
|
+
if (path === "/api/tools" && req.method === "GET") return json(res, await getReportedTools(deps));
|
|
231
|
+
if (path === "/api/blueprint" && req.method === "GET") return json(res, await getBlueprintInfo(deps));
|
|
232
|
+
if (path === "/api/blueprints" && req.method === "GET") return json(res, await getAllBlueprintsInfo(deps));
|
|
233
|
+
// Ailo 连接配置(仅当 configPath 存在时,供桌面端在界面填写并保存)
|
|
234
|
+
if (deps.configPath) {
|
|
235
|
+
if (path === "/api/connection" && req.method === "GET") return json(res, getConnectionConfig(deps.configPath));
|
|
236
|
+
if (path === "/api/connection" && req.method === "POST") return json(res, await saveConnectionConfig(deps.configPath, await body(req), deps.onConnectionConfigSaved));
|
|
237
|
+
}
|
|
238
|
+
// 邮件配置
|
|
239
|
+
if (deps.configPath) {
|
|
240
|
+
if (path === "/api/email/config" && req.method === "GET") return json(res, getEmailConfig(deps.configPath, deps.getEmailStatus));
|
|
241
|
+
if (path === "/api/email/config" && req.method === "POST") return json(res, await saveEmailConfig(deps.configPath, await body(req), deps.onEmailConfigSaved));
|
|
242
|
+
}
|
|
243
|
+
// 飞书配置
|
|
244
|
+
if (deps.configPath) {
|
|
245
|
+
if (path === "/api/feishu/config" && req.method === "GET") return json(res, getPlatformConfig(deps.configPath, "feishu", ["appId", "appSecret"], deps.getFeishuStatus));
|
|
246
|
+
if (path === "/api/feishu/config" && req.method === "POST") return json(res, await savePlatformConfig(deps.configPath, "feishu", ["appId", "appSecret"], await body(req), deps.onFeishuConfigSaved));
|
|
247
|
+
}
|
|
248
|
+
// 钉钉配置
|
|
249
|
+
if (deps.configPath) {
|
|
250
|
+
if (path === "/api/dingtalk/config" && req.method === "GET") return json(res, getPlatformConfig(deps.configPath, "dingtalk", ["clientId", "clientSecret"], deps.getDingtalkStatus));
|
|
251
|
+
if (path === "/api/dingtalk/config" && req.method === "POST") return json(res, await savePlatformConfig(deps.configPath, "dingtalk", ["clientId", "clientSecret"], await body(req), deps.onDingtalkConfigSaved));
|
|
252
|
+
}
|
|
253
|
+
// QQ 配置
|
|
254
|
+
if (deps.configPath) {
|
|
255
|
+
if (path === "/api/qq/config" && req.method === "GET") return json(res, getPlatformConfig(deps.configPath, "qq", ["appId", "appSecret", "apiBase"], deps.getQQStatus));
|
|
256
|
+
if (path === "/api/qq/config" && req.method === "POST") return json(res, await savePlatformConfig(deps.configPath, "qq", ["appId", "appSecret", "apiBase"], await body(req), deps.onQQConfigSaved));
|
|
257
|
+
}
|
|
258
|
+
// MCP
|
|
259
|
+
if (path === "/api/mcp" && req.method === "GET") return json(res, getMCPList(deps.mcpManager));
|
|
260
|
+
if (path === "/api/mcp" && req.method === "POST") return json(res, await deps.mcpManager.handle(JSON.parse(await body(req))));
|
|
261
|
+
res.writeHead(404); res.end("Not Found");
|
|
262
|
+
} catch (e: unknown) {
|
|
263
|
+
res.writeHead(500); res.end(JSON.stringify({ error: errMsg(e) }));
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
wss.on("connection", (ws: WebSocket) => {
|
|
268
|
+
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
|
269
|
+
try {
|
|
270
|
+
const msg = JSON.parse(Buffer.isBuffer(data) ? data.toString("utf-8") : String(data));
|
|
271
|
+
if (msg.type === "register") handleRegister(msg.participantName, ws);
|
|
272
|
+
else if (msg.type === "chat") handleChatMessage(msg.text, msg.participantName, ws);
|
|
273
|
+
} catch {
|
|
274
|
+
getWebchatCtx()?.log("warn", "Failed to parse WebSocket message");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
ws.on("close", () => unbindClient(ws));
|
|
278
|
+
ws.on("error", () => unbindClient(ws));
|
|
279
|
+
});
|
|
280
|
+
server.on("upgrade", (req, socket, head) => {
|
|
281
|
+
const url = new URL(req.url ?? "/", `http://localhost:${deps.port}`);
|
|
282
|
+
if (url.pathname !== "/chat/ws") {
|
|
283
|
+
socket.destroy();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
287
|
+
wss.emit("connection", ws, req);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const ref: ConfigServerRef = {
|
|
292
|
+
notifyContextAttached() {
|
|
293
|
+
if (deps.onWebchatReady) deps.onWebchatReady({ recordAiloReply });
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
server.listen(deps.port, "127.0.0.1", () => {
|
|
298
|
+
console.log(`[config] 配置界面: http://127.0.0.1:${deps.port}`);
|
|
299
|
+
if (deps.onWebchatReady && getWebchatCtx()) deps.onWebchatReady({ recordAiloReply });
|
|
300
|
+
});
|
|
301
|
+
server.on("error", (err: unknown) => {
|
|
302
|
+
const code = err && typeof err === "object" && "code" in err ? (err as NodeJS.ErrnoException).code : undefined;
|
|
303
|
+
if (code === "EADDRINUSE") console.log(`[config] 端口 ${deps.port} 已被占用,跳过配置界面`);
|
|
304
|
+
else console.error("[config] 启动失败:", err instanceof Error ? err.message : err);
|
|
305
|
+
});
|
|
306
|
+
return ref;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function json(res: ServerResponse, data: unknown): void {
|
|
310
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
311
|
+
res.end(JSON.stringify(data, null, 2));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1MB
|
|
315
|
+
|
|
316
|
+
async function body(req: IncomingMessage): Promise<string> {
|
|
317
|
+
const chunks: Buffer[] = [];
|
|
318
|
+
let total = 0;
|
|
319
|
+
for await (const chunk of req) {
|
|
320
|
+
const buf = chunk as Buffer;
|
|
321
|
+
total += buf.length;
|
|
322
|
+
if (total > MAX_BODY_BYTES) throw new Error("Request body too large");
|
|
323
|
+
chunks.push(buf);
|
|
324
|
+
}
|
|
325
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 检测 Node(当前进程即 Node,仅取版本) */
|
|
329
|
+
function checkNode(): EnvRuntimeItem {
|
|
330
|
+
const v = process.version || "";
|
|
331
|
+
return {
|
|
332
|
+
id: "node",
|
|
333
|
+
name: "Node.js",
|
|
334
|
+
description: "JavaScript 代码执行、MCP、桌面端运行环境",
|
|
335
|
+
ok: true,
|
|
336
|
+
detail: v,
|
|
337
|
+
canAutoInstall: false,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 检测 Python:优先 python3,其次 python(Windows 上可能是 python) */
|
|
342
|
+
function checkPython(): EnvRuntimeItem {
|
|
343
|
+
const commands = process.platform === "win32" ? ["python", "python3", "py"] : ["python3", "python"];
|
|
344
|
+
for (const cmd of commands) {
|
|
345
|
+
const r = spawnSync(cmd, ["--version"], { encoding: "utf-8", timeout: 5000 });
|
|
346
|
+
const out = (r.stdout || r.stderr || "").trim();
|
|
347
|
+
if (r.status === 0 && out) {
|
|
348
|
+
const version = out.replace(/^Python\s+/i, "").split(/\s/)[0]?.trim() || out;
|
|
349
|
+
return {
|
|
350
|
+
id: "python",
|
|
351
|
+
name: "Python",
|
|
352
|
+
description: "代码执行、电子表格等 Skills",
|
|
353
|
+
ok: true,
|
|
354
|
+
detail: version,
|
|
355
|
+
canAutoInstall: false,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const platform = process.platform;
|
|
360
|
+
let hint: string;
|
|
361
|
+
if (platform === "win32") {
|
|
362
|
+
hint =
|
|
363
|
+
"1. 从官网下载安装包:https://www.python.org/downloads/\n" +
|
|
364
|
+
"2. 安装时务必勾选「Add Python to PATH」\n" +
|
|
365
|
+
"3. 或使用包管理器:winget install Python.Python.3.12\n" +
|
|
366
|
+
"4. 安装后重启终端,执行 python --version 或 py -3 --version 验证";
|
|
367
|
+
} else if (platform === "darwin") {
|
|
368
|
+
hint =
|
|
369
|
+
"1. 推荐使用 Homebrew:brew install python\n" +
|
|
370
|
+
"2. 或从官网下载:https://www.python.org/downloads/\n" +
|
|
371
|
+
"3. 安装后执行 python3 --version 验证";
|
|
372
|
+
} else {
|
|
373
|
+
hint =
|
|
374
|
+
"1. Debian/Ubuntu:sudo apt update && sudo apt install python3 python3-pip\n" +
|
|
375
|
+
"2. Fedora/RHEL:sudo dnf install python3 python3-pip\n" +
|
|
376
|
+
"3. 或从官网下载:https://www.python.org/downloads/\n" +
|
|
377
|
+
"4. 安装后执行 python3 --version 验证";
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
id: "python",
|
|
381
|
+
name: "Python",
|
|
382
|
+
description: "代码执行、电子表格等 Skills",
|
|
383
|
+
ok: false,
|
|
384
|
+
hint,
|
|
385
|
+
canAutoInstall: false,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 检测 Playwright Chromium 是否已安装 */
|
|
390
|
+
async function checkPlaywright(): Promise<EnvRuntimeItem> {
|
|
391
|
+
try {
|
|
392
|
+
const playwright = await import("playwright");
|
|
393
|
+
const exe = playwright.chromium.executablePath();
|
|
394
|
+
if (existsSync(exe)) {
|
|
395
|
+
return {
|
|
396
|
+
id: "playwright",
|
|
397
|
+
name: "Playwright Chromium",
|
|
398
|
+
description: "浏览器自动化、可见窗口操作、网页抓取等",
|
|
399
|
+
ok: true,
|
|
400
|
+
detail: "已安装",
|
|
401
|
+
canAutoInstall: true,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
} catch { }
|
|
405
|
+
return {
|
|
406
|
+
id: "playwright",
|
|
407
|
+
name: "Playwright Chromium",
|
|
408
|
+
description: "浏览器自动化、可见窗口操作、网页抓取等",
|
|
409
|
+
ok: false,
|
|
410
|
+
hint:
|
|
411
|
+
"本依赖支持一键安装。\n\n" +
|
|
412
|
+
"点击本页下方「安装缺失依赖」按钮,将自动执行 npx playwright install chromium 下载 Chromium 浏览器。\n\n" +
|
|
413
|
+
"若需手动安装:在项目目录或任意目录执行 npx playwright install chromium。",
|
|
414
|
+
canAutoInstall: true,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function getEnvCheck(): Promise<EnvCheckResult> {
|
|
419
|
+
const playwright = await checkPlaywright();
|
|
420
|
+
const runtimes: EnvRuntimeItem[] = [
|
|
421
|
+
checkNode(),
|
|
422
|
+
checkPython(),
|
|
423
|
+
playwright,
|
|
424
|
+
];
|
|
425
|
+
return { runtimes };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 安装可自动安装的依赖(目前仅 Playwright Chromium) */
|
|
429
|
+
async function runEnvInstall(): Promise<{ installed: string[]; errors: string[] }> {
|
|
430
|
+
const { execSync } = await import("child_process");
|
|
431
|
+
const installed: string[] = [];
|
|
432
|
+
const errors: string[] = [];
|
|
433
|
+
try {
|
|
434
|
+
execSync("npx playwright install chromium", { stdio: "pipe", timeout: 120000, encoding: "utf-8" });
|
|
435
|
+
installed.push("Playwright Chromium");
|
|
436
|
+
} catch (e: unknown) {
|
|
437
|
+
errors.push(`Playwright: ${errMsg(e)}`);
|
|
438
|
+
}
|
|
439
|
+
return { installed, errors };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 从蓝图正文中解析 tools 列表(仅提取 name、description) */
|
|
443
|
+
function parseBlueprintTools(md: string): { name: string; description: string }[] {
|
|
444
|
+
const tools: { name: string; description: string }[] = [];
|
|
445
|
+
const frontmatterMatch = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
446
|
+
const yaml = frontmatterMatch?.[1] ?? "";
|
|
447
|
+
let inToolsSection = false;
|
|
448
|
+
let current: { name: string; description: string } | null = null;
|
|
449
|
+
for (const line of yaml.split(/\r?\n/)) {
|
|
450
|
+
if (line.trim() === "tools:") {
|
|
451
|
+
inToolsSection = true;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (!inToolsSection) continue;
|
|
455
|
+
const itemMatch = line.match(/^\s+-\s+name:\s*(.+)$/);
|
|
456
|
+
const descMatch = line.match(/^\s+description:\s*(.+)$/);
|
|
457
|
+
if (itemMatch) {
|
|
458
|
+
if (current) tools.push(current);
|
|
459
|
+
current = { name: itemMatch[1].trim(), description: "" };
|
|
460
|
+
} else if (current && descMatch) {
|
|
461
|
+
current.description = descMatch[1].trim();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (current) tools.push(current);
|
|
465
|
+
return tools;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function fetchBlueprintContent(url: string, localFallbackPath?: string): Promise<string> {
|
|
469
|
+
if (url.startsWith("file://")) {
|
|
470
|
+
const filePath = url.slice(7);
|
|
471
|
+
return readFileSync(filePath, "utf-8");
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const res = await fetch(url);
|
|
475
|
+
if (!res.ok) throw new Error(`Blueprint fetch failed: ${res.status}`);
|
|
476
|
+
return res.text();
|
|
477
|
+
} catch (e) {
|
|
478
|
+
if (localFallbackPath && existsSync(localFallbackPath)) {
|
|
479
|
+
return readFileSync(localFallbackPath, "utf-8");
|
|
480
|
+
}
|
|
481
|
+
throw e;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function getReportedTools(deps: ConfigServerDeps): Promise<{ name: string; description: string; source: string }[]> {
|
|
486
|
+
const out: { name: string; description: string; source: string }[] = [];
|
|
487
|
+
if (deps.blueprintUrl) {
|
|
488
|
+
try {
|
|
489
|
+
const localPath = deps.blueprintLocalPath
|
|
490
|
+
? resolve(process.cwd(), deps.blueprintLocalPath)
|
|
491
|
+
: undefined;
|
|
492
|
+
const md = await fetchBlueprintContent(deps.blueprintUrl, localPath);
|
|
493
|
+
for (const t of parseBlueprintTools(md)) {
|
|
494
|
+
out.push({ name: t.name, description: t.description, source: "builtin" });
|
|
495
|
+
}
|
|
496
|
+
} catch (e) {
|
|
497
|
+
console.error("[config] 解析蓝图工具失败:", e);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const t of deps.mcpManager.getAllPrivateTools()) {
|
|
501
|
+
out.push({ name: t.name, description: t.description ?? "", source: "mcp" });
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function getBlueprintInfo(deps: ConfigServerDeps): Promise<{ url: string | null; content: string | null }> {
|
|
507
|
+
if (!deps.blueprintUrl) return { url: null, content: null };
|
|
508
|
+
try {
|
|
509
|
+
const localPath = deps.blueprintLocalPath ? resolve(process.cwd(), deps.blueprintLocalPath) : undefined;
|
|
510
|
+
const content = await fetchBlueprintContent(deps.blueprintUrl, localPath);
|
|
511
|
+
return { url: deps.blueprintUrl, content };
|
|
512
|
+
} catch {
|
|
513
|
+
return { url: deps.blueprintUrl, content: null };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function getAllBlueprintsInfo(deps: ConfigServerDeps): Promise<{ name: string; path: string; content: string | null }[]> {
|
|
518
|
+
const paths = deps.getBlueprintPaths?.() ?? [];
|
|
519
|
+
if (paths.length === 0) {
|
|
520
|
+
// fallback:如果没有传 getBlueprintPaths,至少返回主蓝图
|
|
521
|
+
if (deps.blueprintUrl) {
|
|
522
|
+
try {
|
|
523
|
+
const localPath = deps.blueprintLocalPath ? resolve(process.cwd(), deps.blueprintLocalPath) : undefined;
|
|
524
|
+
const content = await fetchBlueprintContent(deps.blueprintUrl, localPath);
|
|
525
|
+
const name = basename(deps.blueprintLocalPath ?? deps.blueprintUrl, ".blueprint.md");
|
|
526
|
+
return [{ name, path: deps.blueprintUrl, content }];
|
|
527
|
+
} catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
return Promise.all(
|
|
534
|
+
paths.map(async (p) => {
|
|
535
|
+
const filePath = p.startsWith("file://") ? p.slice(7) : p;
|
|
536
|
+
const name = basename(filePath, ".blueprint.md");
|
|
537
|
+
try {
|
|
538
|
+
const content = readFileSync(filePath, "utf-8");
|
|
539
|
+
return { name, path: p, content };
|
|
540
|
+
} catch {
|
|
541
|
+
return { name, path: p, content: null };
|
|
542
|
+
}
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function getMCPList(mgr: LocalMCPManager) {
|
|
548
|
+
const configs = mgr.getConfigs();
|
|
549
|
+
const servers: Record<string, unknown>[] = [];
|
|
550
|
+
for (const [name, cfg] of configs) {
|
|
551
|
+
const c = cfg as Record<string, unknown>;
|
|
552
|
+
servers.push({
|
|
553
|
+
name,
|
|
554
|
+
transport: c.transport ?? "stdio",
|
|
555
|
+
command: c.command,
|
|
556
|
+
args: c.args,
|
|
557
|
+
url: c.url,
|
|
558
|
+
enabled: c.enabled !== false,
|
|
559
|
+
running: mgr.isRunning(name),
|
|
560
|
+
tools: mgr.getToolsForServer(name).map((t) => ({ name: t.name, description: t.description })),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return { servers };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function serveUI(res: ServerResponse, deps: ConfigServerDeps): void {
|
|
567
|
+
try {
|
|
568
|
+
let html = readFileSync(resolveStaticFile("app.html"), "utf-8");
|
|
569
|
+
html = html.replace(/__SHOW_CONNECTION_FORM__/g, String(!!deps.configPath));
|
|
570
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
571
|
+
res.end(html);
|
|
572
|
+
} catch (e: unknown) {
|
|
573
|
+
res.writeHead(500);
|
|
574
|
+
res.end("Failed to load app.html: " + errMsg(e));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function serveStatic(res: ServerResponse, filename: string): void {
|
|
579
|
+
const contentType = filename.endsWith(".css")
|
|
580
|
+
? "text/css; charset=utf-8"
|
|
581
|
+
: "application/javascript; charset=utf-8";
|
|
582
|
+
try {
|
|
583
|
+
const content = readFileSync(resolveStaticFile(filename), "utf-8");
|
|
584
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
585
|
+
res.end(content);
|
|
586
|
+
} catch {
|
|
587
|
+
res.writeHead(404);
|
|
588
|
+
res.end("Not found: " + filename);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function getConnectionConfig(configPath: string): {
|
|
593
|
+
configured: boolean;
|
|
594
|
+
ailoWsUrl?: string;
|
|
595
|
+
ailoApiKey?: string;
|
|
596
|
+
endpointId?: string;
|
|
597
|
+
} {
|
|
598
|
+
const cfg = readConfig(configPath);
|
|
599
|
+
const url = (getNestedValue(cfg as Record<string, unknown>, "ailo.wsUrl") as string) ?? "";
|
|
600
|
+
const key = (getNestedValue(cfg as Record<string, unknown>, "ailo.apiKey") as string) ?? "";
|
|
601
|
+
const id = (getNestedValue(cfg as Record<string, unknown>, "ailo.endpointId") as string) ?? "";
|
|
602
|
+
const configured = !!(url && key && id);
|
|
603
|
+
return {
|
|
604
|
+
configured,
|
|
605
|
+
ailoWsUrl: url || undefined,
|
|
606
|
+
ailoApiKey: key || undefined,
|
|
607
|
+
endpointId: id || undefined,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function saveConnectionConfig(
|
|
612
|
+
configPath: string,
|
|
613
|
+
bodyStr: string,
|
|
614
|
+
onSaved?: (config: { ailoWsUrl: string; ailoApiKey: string; endpointId: string }) => Promise<void>,
|
|
615
|
+
): Promise<{ ok: boolean; message?: string; error?: string }> {
|
|
616
|
+
try {
|
|
617
|
+
const bodyTrimmed = (bodyStr ?? "").trim();
|
|
618
|
+
if (!bodyTrimmed) return { ok: false, error: "请求体为空" };
|
|
619
|
+
const existing = readConfig(configPath) as Record<string, unknown>;
|
|
620
|
+
const b = JSON.parse(bodyTrimmed) as { ailoWsUrl?: string; ailoApiKey?: string; endpointId?: string };
|
|
621
|
+
if (b.ailoWsUrl !== undefined) setNestedValue(existing, "ailo.wsUrl", b.ailoWsUrl);
|
|
622
|
+
if (b.ailoApiKey !== undefined) setNestedValue(existing, "ailo.apiKey", b.ailoApiKey);
|
|
623
|
+
if (b.endpointId !== undefined) setNestedValue(existing, "ailo.endpointId", b.endpointId);
|
|
624
|
+
writeConfig(configPath, existing);
|
|
625
|
+
const ailoWsUrl = (getNestedValue(existing as Record<string, unknown>, "ailo.wsUrl") as string) ?? "";
|
|
626
|
+
const ailoApiKey = (getNestedValue(existing as Record<string, unknown>, "ailo.apiKey") as string) ?? "";
|
|
627
|
+
const endpointId = (getNestedValue(existing as Record<string, unknown>, "ailo.endpointId") as string) ?? "";
|
|
628
|
+
if (onSaved && ailoWsUrl && ailoApiKey && endpointId) {
|
|
629
|
+
await onSaved({ ailoWsUrl, ailoApiKey, endpointId });
|
|
630
|
+
return { ok: true, message: "已保存,正在使用新配置重连…" };
|
|
631
|
+
}
|
|
632
|
+
return { ok: true, message: "已保存。" };
|
|
633
|
+
} catch (e: unknown) {
|
|
634
|
+
return { ok: false, error: errMsg(e) };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function getEmailConfig(
|
|
639
|
+
configPath: string,
|
|
640
|
+
getStatus?: () => { configured: boolean; running: boolean },
|
|
641
|
+
): Record<string, unknown> {
|
|
642
|
+
const cfg = readConfig(configPath) as Record<string, unknown>;
|
|
643
|
+
const email = (cfg.email ?? {}) as Record<string, unknown>;
|
|
644
|
+
const status = getStatus?.() ?? { configured: false, running: false };
|
|
645
|
+
return {
|
|
646
|
+
imapHost: email.imapHost ?? "",
|
|
647
|
+
imapUser: email.imapUser ?? "",
|
|
648
|
+
imapPassword: email.imapPassword ?? "",
|
|
649
|
+
imapPort: email.imapPort ?? 993,
|
|
650
|
+
smtpHost: email.smtpHost ?? "",
|
|
651
|
+
smtpPort: email.smtpPort ?? 465,
|
|
652
|
+
smtpUser: email.smtpUser ?? "",
|
|
653
|
+
smtpPassword: email.smtpPassword ?? "",
|
|
654
|
+
...status,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function saveEmailConfig(
|
|
659
|
+
configPath: string,
|
|
660
|
+
bodyStr: string,
|
|
661
|
+
onSaved?: () => Promise<void>,
|
|
662
|
+
): Promise<{ ok: boolean; message?: string; error?: string }> {
|
|
663
|
+
try {
|
|
664
|
+
const bodyTrimmed = (bodyStr ?? "").trim();
|
|
665
|
+
if (!bodyTrimmed) return { ok: false, error: "请求体为空" };
|
|
666
|
+
const existing = readConfig(configPath) as Record<string, unknown>;
|
|
667
|
+
const b = JSON.parse(bodyTrimmed) as Record<string, unknown>;
|
|
668
|
+
const emailFields = ["imapHost", "imapUser", "imapPassword", "imapPort", "smtpHost", "smtpPort", "smtpUser", "smtpPassword"];
|
|
669
|
+
for (const field of emailFields) {
|
|
670
|
+
if (b[field] !== undefined) setNestedValue(existing, `email.${field}`, b[field]);
|
|
671
|
+
}
|
|
672
|
+
writeConfig(configPath, existing);
|
|
673
|
+
if (onSaved) {
|
|
674
|
+
await onSaved();
|
|
675
|
+
return { ok: true, message: "已保存,邮件通道正在重启…" };
|
|
676
|
+
}
|
|
677
|
+
return { ok: true, message: "已保存。" };
|
|
678
|
+
} catch (e: unknown) {
|
|
679
|
+
return { ok: false, error: errMsg(e) };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function getPlatformConfig(
|
|
684
|
+
configPath: string,
|
|
685
|
+
platform: string,
|
|
686
|
+
fields: string[],
|
|
687
|
+
getStatus?: () => { configured: boolean; running: boolean },
|
|
688
|
+
): Record<string, unknown> {
|
|
689
|
+
const cfg = readConfig(configPath) as Record<string, unknown>;
|
|
690
|
+
const section = (cfg[platform] ?? {}) as Record<string, unknown>;
|
|
691
|
+
const status = getStatus?.() ?? { configured: false, running: false };
|
|
692
|
+
const result: Record<string, unknown> = { ...status };
|
|
693
|
+
for (const f of fields) {
|
|
694
|
+
result[f] = section[f] ?? "";
|
|
695
|
+
}
|
|
696
|
+
return result;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function savePlatformConfig(
|
|
700
|
+
configPath: string,
|
|
701
|
+
platform: string,
|
|
702
|
+
fields: string[],
|
|
703
|
+
bodyStr: string,
|
|
704
|
+
onSaved?: () => Promise<void>,
|
|
705
|
+
): Promise<{ ok: boolean; message?: string; error?: string }> {
|
|
706
|
+
try {
|
|
707
|
+
const bodyTrimmed = (bodyStr ?? "").trim();
|
|
708
|
+
if (!bodyTrimmed) return { ok: false, error: "请求体为空" };
|
|
709
|
+
const existing = readConfig(configPath) as Record<string, unknown>;
|
|
710
|
+
const b = JSON.parse(bodyTrimmed) as Record<string, unknown>;
|
|
711
|
+
for (const field of fields) {
|
|
712
|
+
if (b[field] !== undefined) setNestedValue(existing, `${platform}.${field}`, b[field]);
|
|
713
|
+
}
|
|
714
|
+
writeConfig(configPath, existing);
|
|
715
|
+
if (onSaved) {
|
|
716
|
+
await onSaved();
|
|
717
|
+
return { ok: true, message: "已保存,通道正在重启…" };
|
|
718
|
+
}
|
|
719
|
+
return { ok: true, message: "已保存。" };
|
|
720
|
+
} catch (e: unknown) {
|
|
721
|
+
return { ok: false, error: errMsg(e) };
|
|
722
|
+
}
|
|
723
|
+
}
|