@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.
Files changed (73) hide show
  1. package/copy-static.mjs +11 -0
  2. package/dist/browser_control.js +767 -0
  3. package/dist/browser_snapshot.js +174 -0
  4. package/dist/cli.js +36 -0
  5. package/dist/code_executor.js +95 -0
  6. package/dist/config_server.js +658 -0
  7. package/dist/connection_util.js +14 -0
  8. package/dist/constants.js +2 -0
  9. package/dist/desktop_state_store.js +57 -0
  10. package/dist/desktop_types.js +1 -0
  11. package/dist/desktop_verifier.js +40 -0
  12. package/dist/dingtalk-handler.js +173 -0
  13. package/dist/dingtalk-types.js +1 -0
  14. package/dist/email_handler.js +501 -0
  15. package/dist/exec_tool.js +90 -0
  16. package/dist/feishu-handler.js +620 -0
  17. package/dist/feishu-types.js +8 -0
  18. package/dist/feishu-utils.js +162 -0
  19. package/dist/fs_tools.js +398 -0
  20. package/dist/index.js +433 -0
  21. package/dist/mcp/config-manager.js +64 -0
  22. package/dist/mcp/index.js +3 -0
  23. package/dist/mcp/rpc.js +109 -0
  24. package/dist/mcp/session.js +140 -0
  25. package/dist/mcp_manager.js +253 -0
  26. package/dist/mouse_keyboard.js +516 -0
  27. package/dist/qq-handler.js +153 -0
  28. package/dist/qq-types.js +15 -0
  29. package/dist/qq-ws.js +178 -0
  30. package/dist/screenshot.js +271 -0
  31. package/dist/skills_hub.js +212 -0
  32. package/dist/skills_manager.js +103 -0
  33. package/dist/static/AGENTS.md +25 -0
  34. package/dist/static/app.css +539 -0
  35. package/dist/static/app.html +292 -0
  36. package/dist/static/app.js +380 -0
  37. package/dist/static/chat.html +994 -0
  38. package/dist/time_tool.js +22 -0
  39. package/dist/utils.js +15 -0
  40. package/package.json +38 -0
  41. package/src/browser_control.ts +739 -0
  42. package/src/browser_snapshot.ts +196 -0
  43. package/src/cli.ts +44 -0
  44. package/src/code_executor.ts +101 -0
  45. package/src/config_server.ts +723 -0
  46. package/src/connection_util.ts +23 -0
  47. package/src/constants.ts +2 -0
  48. package/src/desktop_state_store.ts +64 -0
  49. package/src/desktop_types.ts +44 -0
  50. package/src/desktop_verifier.ts +45 -0
  51. package/src/dingtalk-types.ts +26 -0
  52. package/src/exec_tool.ts +93 -0
  53. package/src/feishu-handler.ts +722 -0
  54. package/src/feishu-types.ts +66 -0
  55. package/src/feishu-utils.ts +174 -0
  56. package/src/fs_tools.ts +411 -0
  57. package/src/index.ts +474 -0
  58. package/src/mcp/config-manager.ts +85 -0
  59. package/src/mcp/index.ts +7 -0
  60. package/src/mcp/rpc.ts +131 -0
  61. package/src/mcp/session.ts +182 -0
  62. package/src/mcp_manager.ts +273 -0
  63. package/src/mouse_keyboard.ts +526 -0
  64. package/src/qq-types.ts +49 -0
  65. package/src/qq-ws.ts +223 -0
  66. package/src/screenshot.ts +297 -0
  67. package/src/static/app.css +539 -0
  68. package/src/static/app.html +292 -0
  69. package/src/static/app.js +380 -0
  70. package/src/static/chat.html +994 -0
  71. package/src/time_tool.ts +24 -0
  72. package/src/utils.ts +22 -0
  73. package/tsconfig.json +13 -0
@@ -0,0 +1,501 @@
1
+ /**
2
+ * 邮件通道 — IMAP IDLE 收信 + nodemailer SMTP 发信
3
+ *
4
+ * 集成在 Desktop 端点中,与 webchat 等通道共享同一 EndpointContext。
5
+ * IMAP IDLE 实现零延迟推送,断线自动重连,UIDVALIDITY 追踪。
6
+ */
7
+ import { ImapFlow } from "imapflow";
8
+ import { simpleParser } from "mailparser";
9
+ import nodemailer from "nodemailer";
10
+ import fs from "fs";
11
+ import os from "os";
12
+ import path from "path";
13
+ import { textPart, mediaPart, classifyMedia, getWorkDir } from "@lmcl/ailo-endpoint-sdk";
14
+ import { errMsg } from "./utils.js";
15
+ import { NO_SUBJECT } from "./constants.js";
16
+ function formatAddress(addr) {
17
+ if (!addr)
18
+ return "";
19
+ return `${addr.name ?? ""} <${addr.address ?? ""}>`.trim();
20
+ }
21
+ function formatToAddresses(to) {
22
+ if (!to)
23
+ return "";
24
+ return (Array.isArray(to) ? to : [to]).flatMap((t) => t.value.map((v) => v.address)).filter(Boolean).join(", ");
25
+ }
26
+ function mapAttachments(attachments) {
27
+ return attachments?.map((a) => ({
28
+ filename: a.filename,
29
+ content: a.content,
30
+ contentType: a.contentType,
31
+ }));
32
+ }
33
+ const RECONNECT_BASE_MS = 2_000;
34
+ const RECONNECT_MAX_MS = 60_000;
35
+ export class EmailHandler {
36
+ config;
37
+ ctx = null;
38
+ imap = null;
39
+ transporter = null;
40
+ stopped = false;
41
+ lastUid = 0;
42
+ uidValidity = 0;
43
+ constructor(config) {
44
+ this.config = config;
45
+ }
46
+ async start(ctx) {
47
+ this.ctx = ctx;
48
+ this.stopped = false;
49
+ this.imapLoop().catch((err) => console.error("[email] imapLoop fatal:", err));
50
+ console.log("[email] 邮件通道已启动");
51
+ }
52
+ async stop() {
53
+ this.stopped = true;
54
+ if (this.imap) {
55
+ try {
56
+ this.imap.close();
57
+ }
58
+ catch { /* ignore */ }
59
+ this.imap = null;
60
+ }
61
+ this.transporter = null;
62
+ this.ctx = null;
63
+ console.log("[email] 邮件通道已停止");
64
+ }
65
+ get running() {
66
+ return !this.stopped && this.ctx !== null;
67
+ }
68
+ // ── IMAP 连接/IDLE 主循环 ──
69
+ async imapLoop() {
70
+ let attempt = 0;
71
+ while (!this.stopped) {
72
+ try {
73
+ await this.connectImap();
74
+ attempt = 0;
75
+ await this.idleLoop();
76
+ }
77
+ catch (err) {
78
+ if (this.stopped)
79
+ break;
80
+ console.error("[email] IMAP error:", errMsg(err));
81
+ }
82
+ if (this.stopped)
83
+ break;
84
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
85
+ attempt++;
86
+ console.error(`[email] IMAP reconnecting in ${delay}ms (attempt ${attempt})`);
87
+ await sleep(delay);
88
+ }
89
+ }
90
+ async connectImap() {
91
+ if (this.imap) {
92
+ try {
93
+ this.imap.close();
94
+ }
95
+ catch { /* ignore */ }
96
+ this.imap = null;
97
+ }
98
+ this.imap = new ImapFlow({
99
+ host: this.config.imapHost,
100
+ port: this.config.imapPort,
101
+ auth: { user: this.config.imapUser, pass: this.config.imapPassword },
102
+ secure: true,
103
+ tls: { rejectUnauthorized: false },
104
+ logger: false,
105
+ });
106
+ await this.imap.connect();
107
+ console.log(`[email] IMAP connected to ${this.config.imapHost}`);
108
+ await this.restoreCheckpoint();
109
+ }
110
+ async idleLoop() {
111
+ while (this.imap?.usable && !this.stopped) {
112
+ const lock = await this.imap.getMailboxLock("INBOX");
113
+ try {
114
+ const box = this.imap.mailbox;
115
+ const validity = box ? Number(box.uidValidity ?? 0) : 0;
116
+ if (validity && validity !== this.uidValidity) {
117
+ if (this.uidValidity !== 0) {
118
+ console.log(`[email] UIDVALIDITY changed (${this.uidValidity} → ${validity}), resetting`);
119
+ }
120
+ this.uidValidity = validity;
121
+ this.lastUid = 0;
122
+ }
123
+ await this.fetchNewMessages();
124
+ }
125
+ finally {
126
+ lock.release();
127
+ }
128
+ try {
129
+ await this.imap.idle();
130
+ }
131
+ catch {
132
+ // IDLE interrupted — loop back
133
+ }
134
+ }
135
+ }
136
+ async fetchNewMessages() {
137
+ if (!this.imap?.usable || !this.ctx)
138
+ return;
139
+ const range = `${this.lastUid + 1}:*`;
140
+ let maxUid = this.lastUid;
141
+ let count = 0;
142
+ try {
143
+ for await (const msg of this.imap.fetch(range, { uid: true, source: true }, { uid: true })) {
144
+ if (!msg || msg.uid <= this.lastUid)
145
+ continue;
146
+ if (msg.uid > maxUid)
147
+ maxUid = msg.uid;
148
+ if (!msg.source)
149
+ continue;
150
+ count++;
151
+ try {
152
+ const parsed = await simpleParser(msg.source);
153
+ await this.emitMessage(parsed);
154
+ }
155
+ catch (err) {
156
+ console.error(`[email] parse uid=${msg.uid} failed:`, errMsg(err));
157
+ }
158
+ }
159
+ }
160
+ catch (err) {
161
+ console.error("[email] fetch error:", errMsg(err));
162
+ }
163
+ if (maxUid > this.lastUid) {
164
+ this.lastUid = maxUid;
165
+ await this.saveCheckpoint();
166
+ if (count > 0)
167
+ console.log(`[email] processed ${count} new message(s), lastUid=${this.lastUid}`);
168
+ }
169
+ }
170
+ // ── Checkpoint 持久化 ──
171
+ async saveCheckpoint() {
172
+ if (!this.ctx)
173
+ return;
174
+ const cp = { lastUid: this.lastUid, uidValidity: this.uidValidity };
175
+ try {
176
+ await this.ctx.storage.setData("email_checkpoint", JSON.stringify(cp));
177
+ }
178
+ catch (err) {
179
+ console.error("[email] save checkpoint failed:", errMsg(err));
180
+ }
181
+ }
182
+ async restoreCheckpoint() {
183
+ if (!this.ctx)
184
+ return;
185
+ try {
186
+ const raw = await this.ctx.storage.getData("email_checkpoint");
187
+ if (!raw)
188
+ return;
189
+ const cp = JSON.parse(raw);
190
+ this.lastUid = cp.lastUid ?? 0;
191
+ this.uidValidity = cp.uidValidity ?? 0;
192
+ console.log(`[email] restored checkpoint: lastUid=${this.lastUid}, uidValidity=${this.uidValidity}`);
193
+ }
194
+ catch (err) {
195
+ console.error("[email] restore checkpoint failed:", errMsg(err));
196
+ }
197
+ }
198
+ // ── 收信推送 ──
199
+ async emitMessage(parsed) {
200
+ if (!this.ctx)
201
+ return;
202
+ const from = parsed.from?.value?.[0];
203
+ const fromAddr = from?.address ?? "unknown";
204
+ const fromName = from?.name ?? fromAddr;
205
+ const subject = parsed.subject ?? NO_SUBJECT;
206
+ const text = parsed.text ?? (typeof parsed.html === "string" ? parsed.html : "") ?? "";
207
+ const attachments = await this.saveAttachments(parsed);
208
+ const contextTags = [
209
+ { kind: "channel", value: "email", groupWith: true },
210
+ { kind: "conv_type", value: "私聊", groupWith: true },
211
+ { kind: "chat_id", value: fromAddr, groupWith: true, passToTool: true },
212
+ { kind: "participant", value: fromName, groupWith: false },
213
+ { kind: "sender_id", value: fromAddr, groupWith: false, passToTool: true },
214
+ ];
215
+ const content = [];
216
+ const bodyText = `[邮件 · 主题: ${subject}]\n\n${text}`.trim();
217
+ if (bodyText)
218
+ content.push(textPart(bodyText));
219
+ for (const a of attachments) {
220
+ const mediaType = classifyMedia(a.mime ?? "application/octet-stream");
221
+ content.push(mediaPart(mediaType, { type: mediaType, path: a.path, mime: a.mime, name: a.name }));
222
+ }
223
+ try {
224
+ await this.ctx.accept({ content, contextTags });
225
+ }
226
+ catch (err) {
227
+ console.error("[email] accept failed:", errMsg(err));
228
+ }
229
+ }
230
+ // ── IMAP 工具操作 ──
231
+ async withMailbox(folder, fn) {
232
+ if (!this.imap?.usable)
233
+ throw new Error("IMAP 未连接");
234
+ const lock = await this.imap.getMailboxLock(folder);
235
+ try {
236
+ return await fn(this.imap);
237
+ }
238
+ finally {
239
+ lock.release();
240
+ }
241
+ }
242
+ async list(opts) {
243
+ const folder = opts.folder ?? "INBOX";
244
+ const limit = clamp(opts.limit ?? 50, 1, 200);
245
+ const offset = Math.max(0, opts.offset ?? 0);
246
+ return this.withMailbox(folder, async (imap) => {
247
+ const criteria = opts.unreadOnly ? { seen: false } : {};
248
+ const result = await imap.search(criteria, { uid: true });
249
+ const uids = Array.isArray(result) ? result : [];
250
+ if (uids.length === 0)
251
+ return [];
252
+ const sorted = [...uids].sort((a, b) => b - a);
253
+ const slice = sorted.slice(offset, offset + limit);
254
+ if (slice.length === 0)
255
+ return [];
256
+ const items = [];
257
+ for await (const msg of imap.fetch(slice.join(","), { uid: true, envelope: true, flags: true }, { uid: true })) {
258
+ items.push({
259
+ uid: msg.uid,
260
+ from: formatAddress(msg.envelope?.from?.[0]),
261
+ to: msg.envelope?.to?.map((a) => a.address).filter(Boolean).join(", ") ?? "",
262
+ subject: msg.envelope?.subject ?? NO_SUBJECT,
263
+ date: msg.envelope?.date?.toISOString() ?? "",
264
+ isRead: msg.flags?.has("\\Seen") ?? false,
265
+ });
266
+ }
267
+ return items.sort((a, b) => b.uid - a.uid);
268
+ });
269
+ }
270
+ async read(opts) {
271
+ const folder = opts.folder ?? "INBOX";
272
+ return this.withMailbox(folder, async (imap) => {
273
+ let raw;
274
+ try {
275
+ raw = await imap.fetchOne(String(opts.uid), { uid: true, source: true }, { uid: true });
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ if (!raw || !raw.source)
281
+ return null;
282
+ const parsed = await simpleParser(raw.source);
283
+ await imap.messageFlagsAdd(String(opts.uid), ["\\Seen"], { uid: true }).catch(() => { });
284
+ return {
285
+ uid: opts.uid,
286
+ from: formatAddress(parsed.from?.value?.[0]),
287
+ to: formatToAddresses(parsed.to),
288
+ subject: parsed.subject ?? NO_SUBJECT,
289
+ date: parsed.date?.toISOString() ?? "",
290
+ text: parsed.text ?? undefined,
291
+ html: typeof parsed.html === "string" ? parsed.html : undefined,
292
+ attachments: (parsed.attachments ?? []).map((a) => ({
293
+ filename: a.filename ?? "attachment",
294
+ contentType: a.contentType ?? "application/octet-stream",
295
+ size: a.size ?? 0,
296
+ })),
297
+ };
298
+ });
299
+ }
300
+ async search(opts) {
301
+ const folder = opts.folder ?? "INBOX";
302
+ const limit = clamp(opts.limit ?? 50, 1, 200);
303
+ return this.withMailbox(folder, async (imap) => {
304
+ const criteria = {};
305
+ if (opts.from)
306
+ criteria.from = opts.from;
307
+ if (opts.to)
308
+ criteria.to = opts.to;
309
+ if (opts.subject)
310
+ criteria.subject = opts.subject;
311
+ if (opts.since)
312
+ criteria.since = new Date(opts.since);
313
+ if (opts.until)
314
+ criteria.before = new Date(opts.until);
315
+ if (opts.query)
316
+ criteria.body = opts.query;
317
+ const result = await imap.search(criteria, { uid: true });
318
+ const uids = Array.isArray(result) ? result : [];
319
+ const sorted = [...uids].sort((a, b) => b - a).slice(0, limit);
320
+ if (sorted.length === 0)
321
+ return [];
322
+ const items = [];
323
+ for await (const msg of imap.fetch(sorted.join(","), { uid: true, envelope: true, flags: true }, { uid: true })) {
324
+ items.push({
325
+ uid: msg.uid,
326
+ from: formatAddress(msg.envelope?.from?.[0]),
327
+ to: msg.envelope?.to?.map((a) => a.address).filter(Boolean).join(", ") ?? "",
328
+ subject: msg.envelope?.subject ?? NO_SUBJECT,
329
+ date: msg.envelope?.date?.toISOString() ?? "",
330
+ isRead: msg.flags?.has("\\Seen") ?? false,
331
+ });
332
+ }
333
+ return items.sort((a, b) => b.uid - a.uid);
334
+ });
335
+ }
336
+ async markRead(opts) {
337
+ const uids = opts.uids.slice(0, 500);
338
+ if (uids.length === 0)
339
+ return;
340
+ await this.withMailbox(opts.folder ?? "INBOX", async (imap) => {
341
+ const range = uids.join(",");
342
+ if (opts.read)
343
+ await imap.messageFlagsAdd(range, ["\\Seen"], { uid: true });
344
+ else
345
+ await imap.messageFlagsRemove(range, ["\\Seen"], { uid: true });
346
+ });
347
+ }
348
+ async move(opts) {
349
+ const uids = opts.uids.slice(0, 500);
350
+ if (uids.length === 0)
351
+ return;
352
+ await this.withMailbox(opts.fromFolder ?? "INBOX", async (imap) => {
353
+ await imap.messageMove(uids.join(","), opts.folder, { uid: true });
354
+ });
355
+ }
356
+ async deleteMessages(opts) {
357
+ const uids = opts.uids.slice(0, 500);
358
+ if (uids.length === 0)
359
+ return;
360
+ await this.withMailbox(opts.folder ?? "INBOX", async (imap) => {
361
+ await imap.messageDelete(uids.join(","), { uid: true });
362
+ });
363
+ }
364
+ async downloadAttachment(opts) {
365
+ return this.withMailbox(opts.folder ?? "INBOX", async (imap) => {
366
+ let raw;
367
+ try {
368
+ raw = await imap.fetchOne(String(opts.uid), { uid: true, source: true }, { uid: true });
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ if (!raw || !raw.source)
374
+ return null;
375
+ const parsed = await simpleParser(raw.source);
376
+ const att = parsed.attachments?.find((a) => (a.filename ?? "") === opts.filename);
377
+ if (!att?.content || !Buffer.isBuffer(att.content))
378
+ return null;
379
+ const outDir = path.join(getWorkDir() ?? os.tmpdir(), "blobs");
380
+ await fs.promises.mkdir(outDir, { recursive: true });
381
+ const safeName = (att.filename ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_");
382
+ const outPath = path.join(outDir, `${Date.now()}_${safeName}`);
383
+ await fs.promises.writeFile(outPath, att.content);
384
+ return outPath;
385
+ });
386
+ }
387
+ // ── SMTP 发信 ──
388
+ ensureTransporter() {
389
+ if (!this.transporter) {
390
+ const host = this.config.smtpHost ?? this.config.imapHost.replace(/^imap\./, "smtp.");
391
+ const port = this.config.smtpPort ?? 465;
392
+ const user = this.config.smtpUser ?? this.config.imapUser;
393
+ const pass = this.config.smtpPassword ?? this.config.imapPassword;
394
+ this.transporter = nodemailer.createTransport({
395
+ host,
396
+ port,
397
+ secure: port === 465,
398
+ auth: { user, pass },
399
+ });
400
+ }
401
+ return this.transporter;
402
+ }
403
+ async send(opts) {
404
+ await this.ensureTransporter().sendMail({
405
+ from: this.config.imapUser,
406
+ to: opts.to,
407
+ cc: opts.cc,
408
+ bcc: opts.bcc,
409
+ subject: opts.subject ?? NO_SUBJECT,
410
+ text: opts.body,
411
+ html: opts.html,
412
+ attachments: mapAttachments(opts.attachments),
413
+ });
414
+ }
415
+ async reply(opts) {
416
+ const parsed = await this.withMailbox(opts.folder ?? "INBOX", async (imap) => {
417
+ const raw = await imap.fetchOne(String(opts.uid), { uid: true, source: true }, { uid: true });
418
+ if (!raw || !raw.source)
419
+ throw new Error(`邮件 uid=${opts.uid} 不存在`);
420
+ return simpleParser(raw.source);
421
+ });
422
+ const to = parsed.from?.value?.[0]?.address ?? "";
423
+ const subj = parsed.subject?.startsWith("Re:") ? parsed.subject : `Re: ${parsed.subject ?? ""}`;
424
+ const refs = Array.isArray(parsed.references)
425
+ ? parsed.references.join(" ")
426
+ : (parsed.references ?? parsed.messageId ?? "");
427
+ await this.ensureTransporter().sendMail({
428
+ from: this.config.imapUser,
429
+ to,
430
+ subject: subj,
431
+ text: opts.body,
432
+ html: opts.html,
433
+ inReplyTo: parsed.messageId,
434
+ references: refs,
435
+ attachments: mapAttachments(opts.attachments),
436
+ });
437
+ }
438
+ async forward(opts) {
439
+ const parsed = await this.withMailbox(opts.folder ?? "INBOX", async (imap) => {
440
+ const raw = await imap.fetchOne(String(opts.uid), { uid: true, source: true }, { uid: true });
441
+ if (!raw || !raw.source)
442
+ throw new Error(`邮件 uid=${opts.uid} 不存在`);
443
+ return simpleParser(raw.source);
444
+ });
445
+ const subj = parsed.subject?.startsWith("Fwd:") ? parsed.subject : `Fwd: ${parsed.subject ?? ""}`;
446
+ const fwdText = parsed.text ?? (typeof parsed.html === "string" ? parsed.html : "");
447
+ await this.ensureTransporter().sendMail({
448
+ from: this.config.imapUser,
449
+ to: opts.to,
450
+ cc: opts.cc,
451
+ bcc: opts.bcc,
452
+ subject: subj,
453
+ text: opts.body ? `${opts.body}\n\n--- 转发内容 ---\n${fwdText}` : fwdText,
454
+ html: opts.body
455
+ ? `<p>${opts.body.replace(/\n/g, "<br>")}</p><hr>${typeof parsed.html === "string" ? parsed.html : ""}`
456
+ : (typeof parsed.html === "string" ? parsed.html : undefined),
457
+ });
458
+ }
459
+ // ── 内部工具 ──
460
+ async saveAttachments(parsed) {
461
+ if (!parsed.attachments?.length)
462
+ return [];
463
+ const workDir = getWorkDir();
464
+ if (!workDir)
465
+ return [];
466
+ const blobsDir = path.join(workDir, "blobs");
467
+ try {
468
+ await fs.promises.mkdir(blobsDir, { recursive: true });
469
+ }
470
+ catch {
471
+ return [];
472
+ }
473
+ const out = [];
474
+ const ts = Date.now();
475
+ for (let i = 0; i < parsed.attachments.length; i++) {
476
+ const a = parsed.attachments[i];
477
+ if (!a?.content || !Buffer.isBuffer(a.content))
478
+ continue;
479
+ const base = (a.filename ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_") || "attachment";
480
+ const ext = path.extname(base) || "";
481
+ const stem = path.basename(base, ext) || "attachment";
482
+ const filename = `${stem}_${ts}_${i}${ext}`;
483
+ const outPath = path.join(blobsDir, filename);
484
+ try {
485
+ await fs.promises.writeFile(outPath, a.content);
486
+ const mime = a.contentType ?? "application/octet-stream";
487
+ out.push({ type: classifyMedia(mime), path: outPath, mime, name: a.filename ?? filename });
488
+ }
489
+ catch {
490
+ /* skip */
491
+ }
492
+ }
493
+ return out;
494
+ }
495
+ }
496
+ function sleep(ms) {
497
+ return new Promise((r) => setTimeout(r, ms));
498
+ }
499
+ function clamp(n, min, max) {
500
+ return Math.max(min, Math.min(max, n));
501
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from "child_process";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { mkdtemp, rm } from "fs/promises";
5
+ const MAX_OUTPUT = 50000;
6
+ const DEFAULT_TIMEOUT_MS = 120_000; // 2 分钟
7
+ /**
8
+ * 异步执行 shell 命令。
9
+ * 立即返回"已启动",命令在后台运行,完成后通过 sendSignal("tool_result") 推送结果给 LLM。
10
+ */
11
+ export async function execTool(ctx, args) {
12
+ const command = String(args.command ?? "").trim();
13
+ if (!command)
14
+ throw new Error("command 必填");
15
+ const cwd = args.cwd || undefined;
16
+ const timeoutSec = Math.max(5, Math.min(600, Number(args.timeout) || DEFAULT_TIMEOUT_MS / 1000));
17
+ const timeoutMs = timeoutSec * 1000;
18
+ const isWin = os.platform() === "win32";
19
+ const shell = isWin ? "powershell" : "/bin/sh";
20
+ const wrappedCommand = isWin
21
+ ? `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${command}`
22
+ : command;
23
+ const shellArgs = isWin ? ["-Command", wrappedCommand] : ["-c", command];
24
+ const env = {
25
+ ...process.env,
26
+ PYTHONIOENCODING: "utf-8",
27
+ PYTHONUTF8: "1",
28
+ };
29
+ // 在临时目录执行命令,避免 cwd 污染
30
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "ailo-exec-"));
31
+ const proc = spawn(shell, shellArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: cwd || tmpDir, env });
32
+ const output = [];
33
+ let sent = false;
34
+ const cleanup = () => {
35
+ if (timeoutHandle) {
36
+ clearTimeout(timeoutHandle);
37
+ timeoutHandle = null;
38
+ }
39
+ rm(tmpDir, { recursive: true, force: true }).catch(() => { });
40
+ };
41
+ const sendResult = (content) => {
42
+ if (sent)
43
+ return;
44
+ sent = true;
45
+ ctx.sendSignal("tool_result", { content });
46
+ cleanup();
47
+ };
48
+ let timeoutHandle = setTimeout(() => {
49
+ timeoutHandle = null;
50
+ try {
51
+ proc.kill("SIGTERM");
52
+ }
53
+ catch { }
54
+ setTimeout(() => { try {
55
+ proc.kill("SIGKILL");
56
+ }
57
+ catch { } }, 2000);
58
+ sendResult(JSON.stringify({
59
+ ok: false,
60
+ type: "timeout",
61
+ command,
62
+ timeoutSec: timeoutMs / 1000,
63
+ message: `超过 ${timeoutMs / 1000}s 未完成,已终止`,
64
+ }));
65
+ }, timeoutMs);
66
+ proc.stdout?.on("data", (d) => output.push(d.toString("utf-8")));
67
+ proc.stderr?.on("data", (d) => output.push(d.toString("utf-8")));
68
+ proc.on("close", (code) => {
69
+ const text = output.join("").trim();
70
+ const truncated = text.length > MAX_OUTPUT
71
+ ? text.slice(0, MAX_OUTPUT / 2) + "\n...[截断]...\n" + text.slice(-MAX_OUTPUT / 2)
72
+ : text;
73
+ sendResult(JSON.stringify({
74
+ ok: true,
75
+ type: "completed",
76
+ command,
77
+ exitCode: code,
78
+ output: truncated,
79
+ }));
80
+ });
81
+ proc.on("error", (err) => {
82
+ sendResult(JSON.stringify({
83
+ ok: false,
84
+ type: "error",
85
+ command,
86
+ message: err.message,
87
+ }));
88
+ });
89
+ return JSON.stringify({ ok: true, type: "started", command });
90
+ }