mu-agent-bridge 1.0.59 → 1.0.60

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.
@@ -261,7 +261,34 @@ class QwenCdpClient {
261
261
  return { text: reply, ...(attachments.length ? { attachments } : {}) };
262
262
  }
263
263
  }
264
- throw new Error("千问办公 UI 超时未返回回复");
264
+ const permission = await this.permissionStatus(sessionUrl);
265
+ throw new Error(permission ?? `QWEN_REPLY_TIMEOUT:千问办公 UI 超时未返回回复(已等待 ${Math.round(timeoutMs / 1000)} 秒);桌面任务未被停止`);
266
+ }
267
+ async permissionStatus(sessionUrl) {
268
+ try {
269
+ if (await this.evaluate("typeof window !== 'undefined' && Boolean(window.electronTRPC)") !== true)
270
+ return;
271
+ const chatId = new URL(sessionUrl).searchParams.get("chat");
272
+ if (!chatId)
273
+ return;
274
+ const response = await this.trpc("agent.getAllStreamingStates", "query", {});
275
+ const states = response.result?.data?.json;
276
+ if (!Array.isArray(states))
277
+ return;
278
+ for (const state of states) {
279
+ if (typeof state?.chatId !== "string" || !chatId.endsWith(state.chatId) || !state.chatId || !Array.isArray(state.pendingInteractions))
280
+ continue;
281
+ for (const interaction of state.pendingInteractions) {
282
+ if (interaction?.kind !== "permission")
283
+ continue;
284
+ const input = interaction.toolInput;
285
+ const details = [interaction.toolName, input?.operation, input?.path].filter(value => typeof value === "string").join(" · ");
286
+ return `QWEN_PERMISSION_REQUIRED:千问办公正在等待权限确认(${details});请在千问桌面中确认或拒绝,当前任务未被停止`;
287
+ }
288
+ }
289
+ }
290
+ catch { /* 旧版客户端不支持该只读接口时,保留原始超时信息。 */ }
291
+ return;
265
292
  }
266
293
  async injectAttachments(items) {
267
294
  const files = items.map(item => {
@@ -458,11 +485,23 @@ class QwenCdpClient {
458
485
  pending.resolve(message);
459
486
  }
460
487
  async ensureVisible() {
461
- await this.evaluate(`(async () => {
488
+ try {
489
+ await this.evaluate(`(async () => {
462
490
  if (document.querySelector('button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]')) throw new Error('千问桌面端正在生成回复');
463
491
  // 隐藏窗口会暂停消息渲染,使桌面端的提交前检查一直等不到确认。
464
492
  if (document.hidden && typeof window.desktopApi?.focusMainWindow === 'function') await window.desktopApi.focusMainWindow();
465
493
  })()`);
494
+ }
495
+ catch (error) {
496
+ let permission;
497
+ try {
498
+ permission = await this.permissionStatus(String(await this.evaluate("location.href")));
499
+ }
500
+ catch { /* 断线时保留原始错误。 */ }
501
+ if (permission)
502
+ throw new Error(permission);
503
+ throw error;
504
+ }
466
505
  }
467
506
  recoverDraft() {
468
507
  return (0, cdp_draft_1.recoverCdpDraft)(this.evaluate.bind(this), exports.inputSelector, 'button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]', this.options.dataDir);
@@ -9,10 +9,9 @@ const cdp_client_1 = require("./cdp-client");
9
9
  const plugin_1 = require("../../plugin");
10
10
  const desktop_launch_1 = require("../desktop-launch");
11
11
  const session_history_1 = require("./session-history");
12
- const CHAT_HEARTBEAT_INTERVAL_MS = 2 * 60 * 60_000;
13
12
  exports.qwenworkManifest = {
14
13
  id: "qwenwork",
15
- version: "1.0.21",
14
+ version: "1.0.22",
16
15
  displayName: "千问办公",
17
16
  entry: "dist/index.js",
18
17
  apiVersion: "1",
@@ -293,39 +292,13 @@ class QwenworkPlugin {
293
292
  this.healthTimer.unref();
294
293
  }
295
294
  async checkHealth() {
296
- if (this.checking || this.busy || this.recovery || this.state === "stopped" || (!this.client?.healthCheck && !this.client?.chatHeartbeat))
295
+ if (this.checking || this.busy || this.recovery || this.state === "stopped" || !this.client)
297
296
  return;
298
- const client = this.client;
299
297
  this.checking = true;
300
298
  await this.enqueue(async () => {
301
299
  try {
302
- if (this.stopped || this.busy)
303
- return;
304
- if (!client.connected()) {
305
- await this.recover();
306
- return;
307
- }
308
- if (client.chatHeartbeat && (!this.lastHeartbeatAttempt || Date.now() - Date.parse(this.lastHeartbeatAttempt) >= CHAT_HEARTBEAT_INTERVAL_MS)) {
309
- this.lastHeartbeatAttempt = new Date().toISOString();
310
- await client.chatHeartbeat();
311
- this.lastHeartbeat = new Date().toISOString();
312
- this.state = "ready";
313
- this.message = "";
314
- }
315
- else {
316
- await client.healthCheck?.();
317
- }
318
- if (this.stopped)
319
- return;
320
- this.failures = 0;
321
- }
322
- catch (error) {
323
- if (this.stopped)
324
- return;
325
- this.state = "degraded";
326
- this.message = error instanceof Error ? error.message : String(error);
327
- this.context?.logger?.error(`千问心跳失败:${this.message}`);
328
- if (++this.failures >= 2 && !client.connected())
300
+ // 健康轮询只看连接状态,不发对话或 CDP 探测,避免打断桌面任务。
301
+ if (!this.stopped && !this.busy && !this.client?.connected())
329
302
  await this.recover();
330
303
  }
331
304
  finally {
@@ -350,8 +323,6 @@ class QwenworkPlugin {
350
323
  else if (this.restart)
351
324
  await this.restart();
352
325
  await client.connect();
353
- if (client.chatHeartbeat)
354
- this.lastHeartbeatAttempt = new Date().toISOString();
355
326
  await this.verifyConnection(client);
356
327
  if (this.stopped) {
357
328
  client.close();
@@ -360,8 +331,6 @@ class QwenworkPlugin {
360
331
  this.state = "ready";
361
332
  this.message = "";
362
333
  this.failures = 0;
363
- if (client.chatHeartbeat)
364
- this.lastHeartbeat = new Date().toISOString();
365
334
  this.restarts += 1;
366
335
  this.context?.logger?.info("千问自动恢复成功", { restarts: this.restarts });
367
336
  })().catch((error) => {
@@ -374,10 +343,8 @@ class QwenworkPlugin {
374
343
  return this.recovery;
375
344
  }
376
345
  async verifyConnection(client) {
377
- if (client.chatHeartbeat)
378
- await client.chatHeartbeat();
379
- else
380
- await client.healthCheck?.();
346
+ if (!client.connected())
347
+ throw new Error("千问 CDP 连接恢复失败");
381
348
  }
382
349
  enqueue(operation) {
383
350
  const run = this.requestQueue.then(operation, operation);
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorkbuddyCdpClient = exports.chineseLocaleScript = exports.assistantSelector = exports.editorSelector = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_url_1 = require("node:url");
5
7
  const attachments_1 = require("../../attachments");
6
8
  function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
7
9
  const interactionOptionSelector = '.conversation-input-area [role="dialog"] button,.pending-sandbox-panel button,button[class*="conversation-"][class*="__option"]';
@@ -21,10 +23,9 @@ exports.chineseLocaleScript = `(() => {
21
23
  const key = 'CODEBUDDY_IDE_STORAGE_LANG';
22
24
  const legacyKey = 'workbuddy-language';
23
25
  const locale = 'zh-CN';
24
- const changed = localStorage.getItem(key) !== locale || localStorage.getItem(legacyKey) !== locale || /[?&]locale=en-US(?:&|$)/i.test(location.search);
26
+ const changed = localStorage.getItem(key) !== locale || localStorage.getItem(legacyKey) !== locale;
25
27
  localStorage.setItem(key, locale);
26
28
  localStorage.setItem(legacyKey, locale);
27
- if (changed) setTimeout(() => location.reload(), 0);
28
29
  return changed;
29
30
  })()`;
30
31
  class WorkbuddyCdpClient {
@@ -49,8 +50,10 @@ class WorkbuddyCdpClient {
49
50
  if (!id)
50
51
  throw new Error("WorkBuddy 会话标识不能为空");
51
52
  const clicked = await this.evaluate(`(() => {
52
- const item = [...document.querySelectorAll('[data-conversation-id]')].find(element => element.getAttribute('data-conversation-id') === ${JSON.stringify(id)});
53
- const button = item?.matches('[role="button"],button') ? item : item?.closest('[role="button"],button');
53
+ const button = [...document.querySelectorAll('[data-conversation-id]')]
54
+ .filter(element => element.getAttribute('data-conversation-id') === ${JSON.stringify(id)})
55
+ .map(item => item.matches('[role="button"],button') ? item : item.querySelector('[role="button"],button') || item.closest('[role="button"],button'))
56
+ .find(item => item && !item.disabled && item.getAttribute('aria-disabled') !== 'true');
54
57
  if (!button || button.getAttribute('aria-disabled') === 'true' || button.disabled) return false;
55
58
  button.click(); return true;
56
59
  })()`);
@@ -105,8 +108,7 @@ class WorkbuddyCdpClient {
105
108
  this.socket.onmessage = (event) => this.onMessage(String(event.data));
106
109
  try {
107
110
  await new Promise((resolve, reject) => { const socket = this.socket; socket.onopen = () => resolve(); socket.onerror = () => reject(new Error("WorkBuddy CDP WebSocket 连接失败")); });
108
- if (await this.evaluate(exports.chineseLocaleScript))
109
- await this.waitFor("!/[?&]locale=en-US(?:&|$)/i.test(location.search)", "WorkBuddy 中文界面切换超时");
111
+ await this.evaluate(exports.chineseLocaleScript);
110
112
  }
111
113
  catch (error) {
112
114
  this.close();
@@ -120,6 +122,8 @@ class WorkbuddyCdpClient {
120
122
  const before = await this.evaluate(`(() => { const state = ${snapshot}; const loginRequired = [...document.querySelectorAll('a,button,[role="button"]')].some(item => /^(?:登录|立即登录|sign in)$/i.test(item.textContent?.trim() ?? '')); return { ...state, loginRequired }; })()`);
121
123
  if (before.loginRequired)
122
124
  throw new Error("WorkBuddy 尚未登录,请先在桌面端登录后重试");
125
+ const taskButton = `(() => { const button = [...document.querySelectorAll('button,[role="button"],[role="tab"]')].find(item => { const label = [item.innerText, item.textContent, item.getAttribute('aria-label'), item.getAttribute('title'), item.getAttribute('data-testid')].filter(Boolean).map(value => String(value).normalize('NFKC').replace(/\\s+/g, ' ').trim().toLowerCase()); return label.some(value => /^(?:新建任务|新建会话|新建对话|开始新任务|new (?:task|chat|conversation))$/.test(value)) && (!item.getClientRects || item.getClientRects().length > 0); }); return Boolean(button && !button.disabled && button.getAttribute('aria-disabled') !== 'true'); })()`;
126
+ await this.waitFor(taskButton, "WorkBuddy 新建任务入口未找到或不可用");
123
127
  if (!await this.evaluate(`(() => { const button = [...document.querySelectorAll('button,[role="button"],[role="tab"]')].find(item => { const label = [item.innerText, item.textContent, item.getAttribute('aria-label'), item.getAttribute('title'), item.getAttribute('data-testid')].filter(Boolean).map(value => String(value).normalize('NFKC').replace(/\\s+/g, ' ').trim().toLowerCase()); return label.some(value => /^(?:新建任务|新建会话|新建对话|开始新任务|new (?:task|chat|conversation))$/.test(value)) && (!item.getClientRects || item.getClientRects().length > 0); }); if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') return false; button.click(); return true; })()`))
124
128
  throw new Error("WorkBuddy 新建任务入口未找到或不可用");
125
129
  // WorkBuddy 首页不分配任务 ID;此时仅接受无选中任务的空白首页。
@@ -437,9 +441,10 @@ class WorkbuddyCdpClient {
437
441
  if (!requestId && typeof props?.requestId === 'string') requestId = props.requestId;
438
442
  }
439
443
  if (artifactProps && requestId && artifactProps.taskId) {
440
- for (const item of artifactProps.allArtifacts) {
441
- if (item.conversationId === artifactProps.taskId && [item.requestId, item.lastModifiedRequestId, item._meta?.createdByRequestId].includes(requestId)) urls.push(item.uri || item.url || '');
442
- }
444
+ const current = artifactProps.allArtifacts.filter(item => item.conversationId === artifactProps.taskId && [item.requestId, item.lastModifiedRequestId, item._meta?.createdByRequestId].includes(requestId));
445
+ // 正式交付文件优先于中间脚本和工作区预览,后者可能已被任务清理。
446
+ const delivered = current.filter(item => item.sourceTool === 'PresentFiles' || item._meta?.sourceTool === 'PresentFiles' || item._meta?.['codebuddy.ai/toolName'] === 'present_files');
447
+ for (const item of delivered.length ? delivered : current) urls.push(item.uri || item.url || '');
443
448
  } else {
444
449
  for (const card of message.querySelectorAll('.artifact-slot-panel__card[data-dir][data-share]')) urls.push(card.getAttribute('data-share') || '');
445
450
  }
@@ -451,7 +456,10 @@ class WorkbuddyCdpClient {
451
456
  if (url.hostname || url.pathname.startsWith('//') || url.search || url.hash || seen.has(url.href)) continue;
452
457
  if (/\\.(?:mp4|webm|mov|m4v|avi|mkv|wmv|flv|mpeg|mpg|3gp)$/i.test(decodeURIComponent(url.pathname))) continue;
453
458
  seen.add(url.href); if (seen.size > ${attachments_1.MAX_ATTACHMENTS}) throw new Error('单条消息最多包含 8 个附件');
454
- const response = await fetch(url.href); if (!response.ok || !response.body) throw new Error('WorkBuddy 产物读取失败');
459
+ let response;
460
+ try { response = await fetch(url.href); }
461
+ catch { output.push({ fileUrl: url.href }); continue; }
462
+ if (!response.ok || !response.body) throw new Error('WorkBuddy 产物读取失败');
455
463
  if (response.headers.get('content-type')?.trim().toLowerCase().startsWith('video/')) { await response.body.cancel(); seen.delete(url.href); continue; }
456
464
  if (Number(response.headers.get('content-length')) > ${attachments_1.MAX_ATTACHMENT_BYTES}) { await response.body.cancel(); throw new Error('单个附件不能超过 32 MiB'); }
457
465
  const reader = response.body.getReader(); let size = 0; let binary = '';
@@ -468,13 +476,44 @@ class WorkbuddyCdpClient {
468
476
  }
469
477
  return output;
470
478
  })()`);
471
- const attachments = Array.from(result, (item) => {
472
- const data = new Uint8Array(Buffer.from(item.data, "base64"));
479
+ const attachments = [];
480
+ for (const item of result) {
481
+ let data;
482
+ if (item.fileUrl) {
483
+ const url = new URL(item.fileUrl);
484
+ if (url.protocol !== "file:" || url.hostname || url.search || url.hash || /^(?:\/{2})|%5c|%2f/i.test(url.pathname))
485
+ throw new Error("WorkBuddy 产物文件 URL 无效");
486
+ const path = (0, node_url_1.fileURLToPath)(url);
487
+ const file = await (0, promises_1.open)(path, "r").catch(error => { throw new Error(`WorkBuddy 产物读取失败:${path}(${error.code ?? String(error)})`); });
488
+ try {
489
+ const stat = await file.stat();
490
+ if (!stat.isFile())
491
+ throw new Error("WorkBuddy 产物不是普通文件");
492
+ if (stat.size > attachments_1.MAX_ATTACHMENT_BYTES)
493
+ throw new Error("单个附件不能超过 32 MiB");
494
+ const chunks = [];
495
+ let size = 0;
496
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
497
+ size += chunk.length;
498
+ if (size > attachments_1.MAX_ATTACHMENT_BYTES)
499
+ throw new Error("单个附件不能超过 32 MiB");
500
+ chunks.push(chunk);
501
+ }
502
+ data = new Uint8Array(Buffer.concat(chunks));
503
+ }
504
+ finally {
505
+ await file.close();
506
+ }
507
+ item.filename = (0, attachments_1.attachmentFilename)(path);
508
+ item.contentType = /\.pdf$/i.test(path) ? "application/pdf" : "application/octet-stream";
509
+ }
510
+ else
511
+ data = new Uint8Array(Buffer.from(item.data, "base64"));
473
512
  const imageType = (0, attachments_1.detectImageContentType)(data);
474
513
  const contentType = imageType ?? (item.contentType.startsWith("image/") ? "application/octet-stream" : item.contentType);
475
- return { kind: imageType ? "image" : "file", data, filename: (0, attachments_1.attachmentFilename)(item.filename), contentType };
476
- });
477
- (0, attachments_1.validateAttachments)(attachments);
514
+ attachments.push({ kind: imageType ? "image" : "file", data, filename: (0, attachments_1.attachmentFilename)(item.filename), contentType });
515
+ (0, attachments_1.validateAttachments)(attachments);
516
+ }
478
517
  return attachments;
479
518
  }
480
519
  async selectExpert(name) {
@@ -15,7 +15,7 @@ const session_history_1 = require("./session-history");
15
15
  const desktop_launch_1 = require("../desktop-launch");
16
16
  const desktop_launch_2 = require("./desktop-launch");
17
17
  exports.workbuddyManifest = {
18
- id: "workbuddy", version: "1.1.19", displayName: "WorkBuddy", type: "agent",
18
+ id: "workbuddy", version: "1.1.26", displayName: "WorkBuddy", type: "agent",
19
19
  entry: "dist/index.js", apiVersion: "1", process: "in-process",
20
20
  capabilities: ["chat", "health"], configSchema: "config.schema.json",
21
21
  };
@@ -236,8 +236,13 @@ class WorkbuddyPlugin {
236
236
  }
237
237
  finally {
238
238
  if (this.activeSessionId && this.desktopSessionValid && this.client?.conversationId) {
239
- this.activeDesktopConversationId = await this.client.conversationId();
240
- await this.persistKnownSessions();
239
+ try {
240
+ this.activeDesktopConversationId = await this.client.conversationId();
241
+ await this.persistKnownSessions();
242
+ }
243
+ catch (error) {
244
+ this.dailyPointsLogger?.warn("WorkBuddy 会话状态同步失败,已保留本轮回复", { stage: "完成后读取会话标识或保存映射", error: error instanceof Error ? error.message : String(error) });
245
+ }
241
246
  }
242
247
  }
243
248
  });
@@ -42,6 +42,33 @@ const node_vm_1 = require("node:vm");
42
42
  const qwenwork_1 = require("../src-ts/plugins/qwenwork");
43
43
  const plugin_1 = require("../src-ts/plugin");
44
44
  const cdp_client_1 = require("../src-ts/plugins/qwenwork/cdp-client");
45
+ (0, node_test_1.default)("千问超时诊断只报告当前对话等待的权限及路径,不批准也不停止任务", async () => {
46
+ const { QwenCdpClient } = await Promise.resolve().then(() => __importStar(require("../src-ts/plugins/qwenwork/cdp-client")));
47
+ const client = new QwenCdpClient();
48
+ const queries = [];
49
+ client.evaluate = async () => true;
50
+ client.trpc = async (path, type) => {
51
+ queries.push(`${type}:${path}`);
52
+ return { result: { type: "data", data: { json: [
53
+ { chatId: "other", pendingInteractions: [{ kind: "permission", toolName: "Write", toolInput: { path: "其它任务.txt" } }] },
54
+ { chatId: "current", pendingInteractions: [{ kind: "permission", toolName: "SandboxFileAccess", toolInput: { operation: "create", path: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\Dictionaries" } }] },
55
+ ] } } };
56
+ };
57
+ const error = await client.permissionStatus("file:///index.html?chat=prefixcurrent");
58
+ strict_1.default.match(error, /QWEN_PERMISSION_REQUIRED.*SandboxFileAccess/);
59
+ strict_1.default.match(error, /Dictionaries/);
60
+ strict_1.default.ok(!error.includes("其它任务.txt"));
61
+ strict_1.default.deepEqual(queries, ["query:agent.getAllStreamingStates"]);
62
+ strict_1.default.equal(await client.permissionStatus("file:///index.html?chat=absent"), undefined);
63
+ client.evaluate = async (expression) => {
64
+ if (expression.includes("千问桌面端正在生成回复"))
65
+ throw new Error("千问桌面端正在生成回复");
66
+ if (expression === "location.href")
67
+ return "file:///index.html?chat=prefixcurrent";
68
+ return true;
69
+ };
70
+ await strict_1.default.rejects(client.ensureVisible(), /QWEN_PERMISSION_REQUIRED.*Dictionaries/);
71
+ });
45
72
  (0, node_test_1.default)("qwenwork manifest declares the dynamic plugin contract", () => {
46
73
  strict_1.default.equal(qwenwork_1.qwenworkManifest.id, "qwenwork");
47
74
  strict_1.default.equal(qwenwork_1.qwenworkManifest.entry, "dist/index.js");
@@ -86,32 +113,23 @@ const cdp_client_1 = require("../src-ts/plugins/qwenwork/cdp-client");
86
113
  strict_1.default.equal(isQwenHeartbeatReplyComplete("MAB_QWEN_HEARTBEAT_12345678", "MAB_QWEN_HEARTBEAT_12345678"), true);
87
114
  strict_1.default.equal(isQwenHeartbeatReplyComplete("API不可用", "MAB_QWEN_HEARTBEAT_12345678"), false);
88
115
  });
89
- (0, node_test_1.default)("空闲真实心跳每 2 小时最多执行一次,期间只检查 CDP", async () => {
90
- let heartbeats = 0;
91
- let healthChecks = 0;
92
- const plugin = new qwenwork_1.QwenworkPlugin({ healthIntervalMs: 60_000, httpPort: 0, client: {
93
- connect: async () => undefined, close: () => undefined, connected: () => true,
94
- send: async () => undefined, direct: async () => "unused",
95
- healthCheck: async () => { healthChecks++; },
96
- chatHeartbeat: async () => { heartbeats++; },
97
- } });
116
+ (0, node_test_1.default)("空闲、降级和重载均不发送心跳,也不调用 CDP 探测", async () => {
117
+ let probes = 0;
118
+ const client = {
119
+ connect: async () => undefined, close: () => undefined, connected: () => true,
120
+ send: async () => undefined, direct: async () => "回复",
121
+ healthCheck: async () => { probes++; }, chatHeartbeat: async () => { probes++; },
122
+ };
123
+ const plugin = new qwenwork_1.QwenworkPlugin({ healthIntervalMs: 60_000, httpPort: 0, client });
98
124
  await plugin.activate({ config: {}, dataDir: "." });
99
- try {
100
- const internal = plugin;
101
- for (let index = 0; index < 100 && (heartbeats === 0 || internal.checking); index++)
102
- await new Promise((resolve) => setTimeout(resolve, 5));
103
- strict_1.default.equal(heartbeats, 1);
104
- await internal.checkHealth();
105
- strict_1.default.equal(heartbeats, 1);
106
- strict_1.default.equal(healthChecks, 1);
107
- internal.lastHeartbeatAttempt = new Date(Date.now() - 2 * 60 * 60_000 - 1).toISOString();
108
- await internal.checkHealth();
109
- strict_1.default.equal(heartbeats, 2);
110
- strict_1.default.equal(healthChecks, 1);
111
- }
112
- finally {
113
- await plugin.deactivate("stop");
114
- }
125
+ await plugin.requestQueue;
126
+ await plugin.checkHealth();
127
+ strict_1.default.equal(probes, 0);
128
+ await plugin.deactivate("reload");
129
+ await plugin.activate({ config: {}, dataDir: "." });
130
+ await plugin.requestQueue;
131
+ strict_1.default.equal(probes, 0);
132
+ await plugin.deactivate("stop");
115
133
  });
116
134
  (0, node_test_1.default)("新会话操作结束前即使 CDP 断开也不检测或重启", async () => {
117
135
  let connected = true;
@@ -181,7 +199,7 @@ const cdp_client_1 = require("../src-ts/plugins/qwenwork/cdp-client");
181
199
  await plugin.deactivate("stop");
182
200
  }
183
201
  });
184
- (0, node_test_1.default)("健康通信失败但 CDP 仍连接时不重启桌面", async () => {
202
+ (0, node_test_1.default)("健康轮询不探测桌面页面,也不因接口故障重启桌面", async () => {
185
203
  let restarts = 0;
186
204
  const plugin = new qwenwork_1.QwenworkPlugin({ healthIntervalMs: 5, httpPort: 0, restartDesktop: async () => { restarts++; }, client: {
187
205
  connect: async () => undefined, close: () => undefined, connected: () => true,
@@ -192,7 +210,7 @@ const cdp_client_1 = require("../src-ts/plugins/qwenwork/cdp-client");
192
210
  try {
193
211
  await new Promise((resolve) => setTimeout(resolve, 25));
194
212
  strict_1.default.equal(restarts, 0);
195
- strict_1.default.equal((await plugin.health()).status, "degraded");
213
+ strict_1.default.equal((await plugin.health()).status, "ready");
196
214
  }
197
215
  finally {
198
216
  await plugin.deactivate("stop");
@@ -375,26 +393,6 @@ const cdp_client_1 = require("../src-ts/plugins/qwenwork/cdp-client");
375
393
  await plugin.deactivate("stop");
376
394
  }
377
395
  });
378
- (0, node_test_1.default)("千问停用等待进行中的健康检查,避免重载重复发送心跳", async () => {
379
- let release;
380
- const plugin = new qwenwork_1.QwenworkPlugin({ httpPort: 0, client: {
381
- connect: async () => undefined, close: () => undefined, connected: () => true,
382
- send: async () => undefined, direct: async () => "回复",
383
- healthCheck: async () => undefined, chatHeartbeat: async () => { await new Promise(resolve => { release = resolve; }); },
384
- } });
385
- await plugin.activate({ config: {}, dataDir: "." });
386
- const internal = plugin;
387
- for (let index = 0; index < 100 && !internal.checking; index++)
388
- await new Promise(resolve => setTimeout(resolve, 1));
389
- const stopping = plugin.deactivate("reload");
390
- let finished = false;
391
- void stopping.then(() => { finished = true; });
392
- await new Promise(resolve => setTimeout(resolve, 5));
393
- strict_1.default.equal(finished, false);
394
- release();
395
- await stopping;
396
- strict_1.default.equal(finished, true);
397
- });
398
396
  (0, node_test_1.default)("定时健康检查失败时只执行一次桌面恢复并重新连接", async () => {
399
397
  let healthy = false;
400
398
  let connected = true;
@@ -40,7 +40,18 @@ const strict_1 = __importDefault(require("node:assert/strict"));
40
40
  const promises_1 = require("node:fs/promises");
41
41
  const node_os_1 = require("node:os");
42
42
  const node_path_1 = require("node:path");
43
+ const node_url_1 = require("node:url");
43
44
  const node_test_1 = __importDefault(require("node:test"));
45
+ (0, node_test_1.default)("WorkBuddy 恢复会话跳过同 ID 的外层容器,点击内部任务按钮", async () => {
46
+ const client = new cdp_client_1.WorkbuddyCdpClient();
47
+ let clicked = false;
48
+ const wrapper = { getAttribute: () => "saved", matches: () => false, closest: () => null, querySelector: () => button };
49
+ const button = { getAttribute: (name) => name === "data-conversation-id" ? "saved" : null, matches: () => true, click: () => { clicked = true; } };
50
+ client.evaluate = async (expression) => (0, node_vm_1.runInNewContext)(expression, { document: { querySelectorAll: () => [wrapper, button] } });
51
+ client.waitFor = async () => { strict_1.default.equal(clicked, true); };
52
+ await client.activateConversation("saved");
53
+ strict_1.default.equal(clicked, true);
54
+ });
44
55
  (0, node_test_1.default)("WorkBuddy 登录页阻止新建验证任务并提示先登录", async () => {
45
56
  const client = new cdp_client_1.WorkbuddyCdpClient();
46
57
  let calls = 0;
@@ -51,11 +62,14 @@ const node_test_1 = __importDefault(require("node:test"));
51
62
  const node_vm_1 = require("node:vm");
52
63
  const workbuddy_1 = require("../src-ts/plugins/workbuddy");
53
64
  const cdp_client_1 = require("../src-ts/plugins/workbuddy/cdp-client");
54
- (0, node_test_1.default)("WorkBuddy CDP 启动后固定中文语言偏好", () => {
65
+ (0, node_test_1.default)("WorkBuddy 连接只保存中文语言偏好,不重载或导航桌面任务", () => {
55
66
  strict_1.default.match(cdp_client_1.chineseLocaleScript, /CODEBUDDY_IDE_STORAGE_LANG/);
56
67
  strict_1.default.match(cdp_client_1.chineseLocaleScript, /workbuddy-language/);
57
68
  strict_1.default.match(cdp_client_1.chineseLocaleScript, /zh-CN/);
58
- strict_1.default.match(cdp_client_1.chineseLocaleScript, /location\.reload/);
69
+ const values = new Map();
70
+ (0, node_vm_1.runInNewContext)(cdp_client_1.chineseLocaleScript, { localStorage: { getItem: (key) => values.get(key), setItem: (key, value) => values.set(key, value) } });
71
+ strict_1.default.equal(values.get("CODEBUDDY_IDE_STORAGE_LANG"), "zh-CN");
72
+ strict_1.default.equal(values.get("workbuddy-language"), "zh-CN");
59
73
  });
60
74
  (0, node_test_1.default)("workbuddy CDP 拒绝连接其他应用的调试页面", async () => {
61
75
  const client = new cdp_client_1.WorkbuddyCdpClient({ fetchImpl: async () => new Response(JSON.stringify([{ type: "page", title: "Other App", webSocketDebuggerUrl: "ws://127.0.0.1:9229/devtools/page/1" }])) });
@@ -295,6 +309,27 @@ const cdp_client_1 = require("../src-ts/plugins/workbuddy/cdp-client");
295
309
  internal.evaluate = async (expression) => (0, node_vm_1.runInNewContext)(expression, { document: { querySelectorAll: () => [message] }, URL, Uint8Array, btoa, fetch: async () => new Response(null, { status: 404 }) });
296
310
  await strict_1.default.rejects(() => internal.readArtifacts(new Set(["new"])), /产物读取失败/);
297
311
  });
312
+ (0, node_test_1.default)("WorkBuddy 浏览器拒绝 file URL 时由 daemon 读取本轮产物", async () => {
313
+ const directory = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "wb-artifact-local-"));
314
+ const file = (0, node_path_1.join)(directory, "打印结果.pdf");
315
+ const bytes = Buffer.from("%PDF-1.7\n本地文件回传");
316
+ await (0, promises_1.writeFile)(file, bytes);
317
+ const card = { getAttribute: () => (0, node_url_1.pathToFileURL)(file).href };
318
+ const message = { getAttribute: () => "new", querySelector: () => null, querySelectorAll: () => [card] };
319
+ const client = new cdp_client_1.WorkbuddyCdpClient();
320
+ const internal = client;
321
+ internal.evaluate = expression => (0, node_vm_1.runInNewContext)(expression, { document: { querySelectorAll: () => [message] }, URL, btoa, fetch: async () => { throw new TypeError("Failed to fetch"); } });
322
+ try {
323
+ const result = await internal.readArtifacts(new Set(["new"]));
324
+ strict_1.default.equal(result[0].filename, "打印结果.pdf");
325
+ strict_1.default.deepEqual(Buffer.from(result[0].data), bytes);
326
+ await (0, promises_1.rm)(file);
327
+ await strict_1.default.rejects(internal.readArtifacts(new Set(["new"])), /ENOENT/);
328
+ }
329
+ finally {
330
+ await (0, promises_1.rm)(directory, { recursive: true, force: true });
331
+ }
332
+ });
298
333
  (0, node_test_1.default)("workbuddy 不回传视频产物,保留图片和普通附件", async () => {
299
334
  const client = new cdp_client_1.WorkbuddyCdpClient();
300
335
  const fetched = [];
@@ -351,6 +386,10 @@ const cdp_client_1 = require("../src-ts/plugins/workbuddy/cdp-client");
351
386
  strict_1.default.equal(output[0].filename, "image.png");
352
387
  strict_1.default.equal(output[0].kind, "image");
353
388
  strict_1.default.deepEqual(fetched, ["file:///C:/image.png"]);
389
+ artifacts.push({ uri: "file:///C:/deleted-preview.png", conversationId: "task-a", requestId: "request-new", sourceTool: "WorkspaceWatcher" }, { uri: "file:///C:/final.pdf", conversationId: "task-a", requestId: "request-new", sourceTool: "PresentFiles" });
390
+ fetched.length = 0;
391
+ await internal.readArtifacts(new Set(["new"]));
392
+ strict_1.default.deepEqual(fetched, ["file:///C:/final.pdf"]);
354
393
  });
355
394
  (0, node_test_1.default)("workbuddy CDP 在读取产物正文前拒绝超大文件", async () => {
356
395
  const client = new cdp_client_1.WorkbuddyCdpClient();
@@ -550,6 +589,22 @@ const cdp_client_1 = require("../src-ts/plugins/workbuddy/cdp-client");
550
589
  strict_1.default.deepEqual(await internal.waitForReply(new Set(), 4_000, undefined, undefined, "peer", async (text) => { forwarded.push(text); }), { text: "视频已生成" });
551
590
  strict_1.default.deepEqual(forwarded, ["请补充视频时长"]);
552
591
  });
592
+ (0, node_test_1.default)("workbuddy 完成后读取桌面会话标识失败,不覆盖最终回复和 PDF", async () => {
593
+ const output = { kind: "file", filename: "知识梳理单.pdf", contentType: "application/pdf", data: new Uint8Array([1, 2, 3]) };
594
+ const warnings = [];
595
+ const plugin = new workbuddy_1.WorkbuddyPlugin({ client: {
596
+ connect: async () => undefined, close() { }, connected: () => true,
597
+ direct: async () => "PDF 已整理完成",
598
+ createTask: async () => undefined,
599
+ complete: async () => ({ text: "PDF 已整理完成", attachments: [output] }),
600
+ conversationId: async () => { throw new Error("WorkBuddy 页面 JS 异常:TypeError: Failed to fetch"); },
601
+ } });
602
+ await plugin.activate({ config: { transport: "cdp" }, logger: { info() { }, warn(message, fields) { warnings.push({ message, fields }); }, error() { } } });
603
+ const response = await plugin.handle({ task: "整理成 PDF", sessionId: "session-a" });
604
+ strict_1.default.deepEqual(response, { text: "PDF 已整理完成", attachments: [output], sessionId: "session-a" });
605
+ strict_1.default.match(warnings[0]?.message ?? "", /会话状态同步失败,已保留本轮回复/);
606
+ strict_1.default.match(JSON.stringify(warnings[0]?.fields), /Failed to fetch/);
607
+ });
553
608
  (0, node_test_1.default)("workbuddy CDP 多条可见消息逐条回传,不只取最新一条", async () => {
554
609
  const client = new cdp_client_1.WorkbuddyCdpClient();
555
610
  const forwarded = [];
@@ -962,6 +1017,29 @@ const cdp_client_1 = require("../src-ts/plugins/workbuddy/cdp-client");
962
1017
  await client.createTask();
963
1018
  }
964
1019
  });
1020
+ (0, node_test_1.default)("workbuddy CDP 等待新建任务入口完成渲染后再点击", async () => {
1021
+ let lookups = 0;
1022
+ let clicked = false;
1023
+ let messages = 1;
1024
+ let conversationId = "old-conversation";
1025
+ const button = { textContent: "新建任务", disabled: false, getAttribute: () => null, getClientRects: () => [{}], click: () => { clicked = true; messages = 0; conversationId = null; } };
1026
+ const editor = { innerText: "今天帮你做些什么?", querySelector: () => clicked ? {} : null, cloneNode: () => ({ textContent: "", querySelectorAll: () => [] }) };
1027
+ const document = {
1028
+ querySelectorAll: (selector) => {
1029
+ if (selector === '.cr-agent[data-message-id]')
1030
+ return Array(messages).fill({});
1031
+ if (selector === '[data-conversation-id]')
1032
+ return conversationId ? [{ className: "_selected_old", getAttribute: () => conversationId }] : [];
1033
+ return ++lookups < 3 ? [] : [button];
1034
+ },
1035
+ querySelector: () => editor,
1036
+ };
1037
+ const client = new cdp_client_1.WorkbuddyCdpClient({ timeoutMs: 500 });
1038
+ const internal = client;
1039
+ internal.evaluate = async (expression) => (0, node_vm_1.runInNewContext)(expression, { document, location: { href: "app://workbuddy/task/old" } });
1040
+ await client.createTask();
1041
+ strict_1.default.equal(clicked, true);
1042
+ });
965
1043
  (0, node_test_1.default)("workbuddy CDP 通过无障碍名称识别无可见文字的新建任务图标", async () => {
966
1044
  let conversationId = "old-conversation";
967
1045
  let messages = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-agent-bridge",
3
- "version": "1.0.59",
3
+ "version": "1.0.60",
4
4
  "description": "将 AUN Agent 连接到本地桌面 AI 客户端的插件平台。",
5
5
  "main": "dist/src-ts/cli.js",
6
6
  "bin": {
@@ -261,7 +261,34 @@ class QwenCdpClient {
261
261
  return { text: reply, ...(attachments.length ? { attachments } : {}) };
262
262
  }
263
263
  }
264
- throw new Error("千问办公 UI 超时未返回回复");
264
+ const permission = await this.permissionStatus(sessionUrl);
265
+ throw new Error(permission ?? `QWEN_REPLY_TIMEOUT:千问办公 UI 超时未返回回复(已等待 ${Math.round(timeoutMs / 1000)} 秒);桌面任务未被停止`);
266
+ }
267
+ async permissionStatus(sessionUrl) {
268
+ try {
269
+ if (await this.evaluate("typeof window !== 'undefined' && Boolean(window.electronTRPC)") !== true)
270
+ return;
271
+ const chatId = new URL(sessionUrl).searchParams.get("chat");
272
+ if (!chatId)
273
+ return;
274
+ const response = await this.trpc("agent.getAllStreamingStates", "query", {});
275
+ const states = response.result?.data?.json;
276
+ if (!Array.isArray(states))
277
+ return;
278
+ for (const state of states) {
279
+ if (typeof state?.chatId !== "string" || !chatId.endsWith(state.chatId) || !state.chatId || !Array.isArray(state.pendingInteractions))
280
+ continue;
281
+ for (const interaction of state.pendingInteractions) {
282
+ if (interaction?.kind !== "permission")
283
+ continue;
284
+ const input = interaction.toolInput;
285
+ const details = [interaction.toolName, input?.operation, input?.path].filter(value => typeof value === "string").join(" · ");
286
+ return `QWEN_PERMISSION_REQUIRED:千问办公正在等待权限确认(${details});请在千问桌面中确认或拒绝,当前任务未被停止`;
287
+ }
288
+ }
289
+ }
290
+ catch { /* 旧版客户端不支持该只读接口时,保留原始超时信息。 */ }
291
+ return;
265
292
  }
266
293
  async injectAttachments(items) {
267
294
  const files = items.map(item => {
@@ -458,11 +485,23 @@ class QwenCdpClient {
458
485
  pending.resolve(message);
459
486
  }
460
487
  async ensureVisible() {
461
- await this.evaluate(`(async () => {
488
+ try {
489
+ await this.evaluate(`(async () => {
462
490
  if (document.querySelector('button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]')) throw new Error('千问桌面端正在生成回复');
463
491
  // 隐藏窗口会暂停消息渲染,使桌面端的提交前检查一直等不到确认。
464
492
  if (document.hidden && typeof window.desktopApi?.focusMainWindow === 'function') await window.desktopApi.focusMainWindow();
465
493
  })()`);
494
+ }
495
+ catch (error) {
496
+ let permission;
497
+ try {
498
+ permission = await this.permissionStatus(String(await this.evaluate("location.href")));
499
+ }
500
+ catch { /* 断线时保留原始错误。 */ }
501
+ if (permission)
502
+ throw new Error(permission);
503
+ throw error;
504
+ }
466
505
  }
467
506
  recoverDraft() {
468
507
  return (0, cdp_draft_1.recoverCdpDraft)(this.evaluate.bind(this), exports.inputSelector, 'button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]', this.options.dataDir);
@@ -9,10 +9,9 @@ const cdp_client_1 = require("./cdp-client");
9
9
  const plugin_1 = require("../../plugin");
10
10
  const desktop_launch_1 = require("../desktop-launch");
11
11
  const session_history_1 = require("./session-history");
12
- const CHAT_HEARTBEAT_INTERVAL_MS = 2 * 60 * 60_000;
13
12
  exports.qwenworkManifest = {
14
13
  id: "qwenwork",
15
- version: "1.0.21",
14
+ version: "1.0.22",
16
15
  displayName: "千问办公",
17
16
  entry: "dist/index.js",
18
17
  apiVersion: "1",
@@ -293,39 +292,13 @@ class QwenworkPlugin {
293
292
  this.healthTimer.unref();
294
293
  }
295
294
  async checkHealth() {
296
- if (this.checking || this.busy || this.recovery || this.state === "stopped" || (!this.client?.healthCheck && !this.client?.chatHeartbeat))
295
+ if (this.checking || this.busy || this.recovery || this.state === "stopped" || !this.client)
297
296
  return;
298
- const client = this.client;
299
297
  this.checking = true;
300
298
  await this.enqueue(async () => {
301
299
  try {
302
- if (this.stopped || this.busy)
303
- return;
304
- if (!client.connected()) {
305
- await this.recover();
306
- return;
307
- }
308
- if (client.chatHeartbeat && (!this.lastHeartbeatAttempt || Date.now() - Date.parse(this.lastHeartbeatAttempt) >= CHAT_HEARTBEAT_INTERVAL_MS)) {
309
- this.lastHeartbeatAttempt = new Date().toISOString();
310
- await client.chatHeartbeat();
311
- this.lastHeartbeat = new Date().toISOString();
312
- this.state = "ready";
313
- this.message = "";
314
- }
315
- else {
316
- await client.healthCheck?.();
317
- }
318
- if (this.stopped)
319
- return;
320
- this.failures = 0;
321
- }
322
- catch (error) {
323
- if (this.stopped)
324
- return;
325
- this.state = "degraded";
326
- this.message = error instanceof Error ? error.message : String(error);
327
- this.context?.logger?.error(`千问心跳失败:${this.message}`);
328
- if (++this.failures >= 2 && !client.connected())
300
+ // 健康轮询只看连接状态,不发对话或 CDP 探测,避免打断桌面任务。
301
+ if (!this.stopped && !this.busy && !this.client?.connected())
329
302
  await this.recover();
330
303
  }
331
304
  finally {
@@ -350,8 +323,6 @@ class QwenworkPlugin {
350
323
  else if (this.restart)
351
324
  await this.restart();
352
325
  await client.connect();
353
- if (client.chatHeartbeat)
354
- this.lastHeartbeatAttempt = new Date().toISOString();
355
326
  await this.verifyConnection(client);
356
327
  if (this.stopped) {
357
328
  client.close();
@@ -360,8 +331,6 @@ class QwenworkPlugin {
360
331
  this.state = "ready";
361
332
  this.message = "";
362
333
  this.failures = 0;
363
- if (client.chatHeartbeat)
364
- this.lastHeartbeat = new Date().toISOString();
365
334
  this.restarts += 1;
366
335
  this.context?.logger?.info("千问自动恢复成功", { restarts: this.restarts });
367
336
  })().catch((error) => {
@@ -374,10 +343,8 @@ class QwenworkPlugin {
374
343
  return this.recovery;
375
344
  }
376
345
  async verifyConnection(client) {
377
- if (client.chatHeartbeat)
378
- await client.chatHeartbeat();
379
- else
380
- await client.healthCheck?.();
346
+ if (!client.connected())
347
+ throw new Error("千问 CDP 连接恢复失败");
381
348
  }
382
349
  enqueue(operation) {
383
350
  const run = this.requestQueue.then(operation, operation);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bridge-qwenwork",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "agent-bridge 的千问办公插件。",
5
5
  "keywords": [
6
6
  "mu-agent-bridge-plugin",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "qwenwork",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "type": "agent",
5
5
  "displayName": "千问办公",
6
6
  "entry": "dist/src-ts/plugins/qwenwork/index.js",
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorkbuddyCdpClient = exports.chineseLocaleScript = exports.assistantSelector = exports.editorSelector = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_url_1 = require("node:url");
5
7
  const attachments_1 = require("../../attachments");
6
8
  function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
7
9
  const interactionOptionSelector = '.conversation-input-area [role="dialog"] button,.pending-sandbox-panel button,button[class*="conversation-"][class*="__option"]';
@@ -21,10 +23,9 @@ exports.chineseLocaleScript = `(() => {
21
23
  const key = 'CODEBUDDY_IDE_STORAGE_LANG';
22
24
  const legacyKey = 'workbuddy-language';
23
25
  const locale = 'zh-CN';
24
- const changed = localStorage.getItem(key) !== locale || localStorage.getItem(legacyKey) !== locale || /[?&]locale=en-US(?:&|$)/i.test(location.search);
26
+ const changed = localStorage.getItem(key) !== locale || localStorage.getItem(legacyKey) !== locale;
25
27
  localStorage.setItem(key, locale);
26
28
  localStorage.setItem(legacyKey, locale);
27
- if (changed) setTimeout(() => location.reload(), 0);
28
29
  return changed;
29
30
  })()`;
30
31
  class WorkbuddyCdpClient {
@@ -49,8 +50,10 @@ class WorkbuddyCdpClient {
49
50
  if (!id)
50
51
  throw new Error("WorkBuddy 会话标识不能为空");
51
52
  const clicked = await this.evaluate(`(() => {
52
- const item = [...document.querySelectorAll('[data-conversation-id]')].find(element => element.getAttribute('data-conversation-id') === ${JSON.stringify(id)});
53
- const button = item?.matches('[role="button"],button') ? item : item?.closest('[role="button"],button');
53
+ const button = [...document.querySelectorAll('[data-conversation-id]')]
54
+ .filter(element => element.getAttribute('data-conversation-id') === ${JSON.stringify(id)})
55
+ .map(item => item.matches('[role="button"],button') ? item : item.querySelector('[role="button"],button') || item.closest('[role="button"],button'))
56
+ .find(item => item && !item.disabled && item.getAttribute('aria-disabled') !== 'true');
54
57
  if (!button || button.getAttribute('aria-disabled') === 'true' || button.disabled) return false;
55
58
  button.click(); return true;
56
59
  })()`);
@@ -105,8 +108,7 @@ class WorkbuddyCdpClient {
105
108
  this.socket.onmessage = (event) => this.onMessage(String(event.data));
106
109
  try {
107
110
  await new Promise((resolve, reject) => { const socket = this.socket; socket.onopen = () => resolve(); socket.onerror = () => reject(new Error("WorkBuddy CDP WebSocket 连接失败")); });
108
- if (await this.evaluate(exports.chineseLocaleScript))
109
- await this.waitFor("!/[?&]locale=en-US(?:&|$)/i.test(location.search)", "WorkBuddy 中文界面切换超时");
111
+ await this.evaluate(exports.chineseLocaleScript);
110
112
  }
111
113
  catch (error) {
112
114
  this.close();
@@ -120,6 +122,8 @@ class WorkbuddyCdpClient {
120
122
  const before = await this.evaluate(`(() => { const state = ${snapshot}; const loginRequired = [...document.querySelectorAll('a,button,[role="button"]')].some(item => /^(?:登录|立即登录|sign in)$/i.test(item.textContent?.trim() ?? '')); return { ...state, loginRequired }; })()`);
121
123
  if (before.loginRequired)
122
124
  throw new Error("WorkBuddy 尚未登录,请先在桌面端登录后重试");
125
+ const taskButton = `(() => { const button = [...document.querySelectorAll('button,[role="button"],[role="tab"]')].find(item => { const label = [item.innerText, item.textContent, item.getAttribute('aria-label'), item.getAttribute('title'), item.getAttribute('data-testid')].filter(Boolean).map(value => String(value).normalize('NFKC').replace(/\\s+/g, ' ').trim().toLowerCase()); return label.some(value => /^(?:新建任务|新建会话|新建对话|开始新任务|new (?:task|chat|conversation))$/.test(value)) && (!item.getClientRects || item.getClientRects().length > 0); }); return Boolean(button && !button.disabled && button.getAttribute('aria-disabled') !== 'true'); })()`;
126
+ await this.waitFor(taskButton, "WorkBuddy 新建任务入口未找到或不可用");
123
127
  if (!await this.evaluate(`(() => { const button = [...document.querySelectorAll('button,[role="button"],[role="tab"]')].find(item => { const label = [item.innerText, item.textContent, item.getAttribute('aria-label'), item.getAttribute('title'), item.getAttribute('data-testid')].filter(Boolean).map(value => String(value).normalize('NFKC').replace(/\\s+/g, ' ').trim().toLowerCase()); return label.some(value => /^(?:新建任务|新建会话|新建对话|开始新任务|new (?:task|chat|conversation))$/.test(value)) && (!item.getClientRects || item.getClientRects().length > 0); }); if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') return false; button.click(); return true; })()`))
124
128
  throw new Error("WorkBuddy 新建任务入口未找到或不可用");
125
129
  // WorkBuddy 首页不分配任务 ID;此时仅接受无选中任务的空白首页。
@@ -437,9 +441,10 @@ class WorkbuddyCdpClient {
437
441
  if (!requestId && typeof props?.requestId === 'string') requestId = props.requestId;
438
442
  }
439
443
  if (artifactProps && requestId && artifactProps.taskId) {
440
- for (const item of artifactProps.allArtifacts) {
441
- if (item.conversationId === artifactProps.taskId && [item.requestId, item.lastModifiedRequestId, item._meta?.createdByRequestId].includes(requestId)) urls.push(item.uri || item.url || '');
442
- }
444
+ const current = artifactProps.allArtifacts.filter(item => item.conversationId === artifactProps.taskId && [item.requestId, item.lastModifiedRequestId, item._meta?.createdByRequestId].includes(requestId));
445
+ // 正式交付文件优先于中间脚本和工作区预览,后者可能已被任务清理。
446
+ const delivered = current.filter(item => item.sourceTool === 'PresentFiles' || item._meta?.sourceTool === 'PresentFiles' || item._meta?.['codebuddy.ai/toolName'] === 'present_files');
447
+ for (const item of delivered.length ? delivered : current) urls.push(item.uri || item.url || '');
443
448
  } else {
444
449
  for (const card of message.querySelectorAll('.artifact-slot-panel__card[data-dir][data-share]')) urls.push(card.getAttribute('data-share') || '');
445
450
  }
@@ -451,7 +456,10 @@ class WorkbuddyCdpClient {
451
456
  if (url.hostname || url.pathname.startsWith('//') || url.search || url.hash || seen.has(url.href)) continue;
452
457
  if (/\\.(?:mp4|webm|mov|m4v|avi|mkv|wmv|flv|mpeg|mpg|3gp)$/i.test(decodeURIComponent(url.pathname))) continue;
453
458
  seen.add(url.href); if (seen.size > ${attachments_1.MAX_ATTACHMENTS}) throw new Error('单条消息最多包含 8 个附件');
454
- const response = await fetch(url.href); if (!response.ok || !response.body) throw new Error('WorkBuddy 产物读取失败');
459
+ let response;
460
+ try { response = await fetch(url.href); }
461
+ catch { output.push({ fileUrl: url.href }); continue; }
462
+ if (!response.ok || !response.body) throw new Error('WorkBuddy 产物读取失败');
455
463
  if (response.headers.get('content-type')?.trim().toLowerCase().startsWith('video/')) { await response.body.cancel(); seen.delete(url.href); continue; }
456
464
  if (Number(response.headers.get('content-length')) > ${attachments_1.MAX_ATTACHMENT_BYTES}) { await response.body.cancel(); throw new Error('单个附件不能超过 32 MiB'); }
457
465
  const reader = response.body.getReader(); let size = 0; let binary = '';
@@ -468,13 +476,44 @@ class WorkbuddyCdpClient {
468
476
  }
469
477
  return output;
470
478
  })()`);
471
- const attachments = Array.from(result, (item) => {
472
- const data = new Uint8Array(Buffer.from(item.data, "base64"));
479
+ const attachments = [];
480
+ for (const item of result) {
481
+ let data;
482
+ if (item.fileUrl) {
483
+ const url = new URL(item.fileUrl);
484
+ if (url.protocol !== "file:" || url.hostname || url.search || url.hash || /^(?:\/{2})|%5c|%2f/i.test(url.pathname))
485
+ throw new Error("WorkBuddy 产物文件 URL 无效");
486
+ const path = (0, node_url_1.fileURLToPath)(url);
487
+ const file = await (0, promises_1.open)(path, "r").catch(error => { throw new Error(`WorkBuddy 产物读取失败:${path}(${error.code ?? String(error)})`); });
488
+ try {
489
+ const stat = await file.stat();
490
+ if (!stat.isFile())
491
+ throw new Error("WorkBuddy 产物不是普通文件");
492
+ if (stat.size > attachments_1.MAX_ATTACHMENT_BYTES)
493
+ throw new Error("单个附件不能超过 32 MiB");
494
+ const chunks = [];
495
+ let size = 0;
496
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
497
+ size += chunk.length;
498
+ if (size > attachments_1.MAX_ATTACHMENT_BYTES)
499
+ throw new Error("单个附件不能超过 32 MiB");
500
+ chunks.push(chunk);
501
+ }
502
+ data = new Uint8Array(Buffer.concat(chunks));
503
+ }
504
+ finally {
505
+ await file.close();
506
+ }
507
+ item.filename = (0, attachments_1.attachmentFilename)(path);
508
+ item.contentType = /\.pdf$/i.test(path) ? "application/pdf" : "application/octet-stream";
509
+ }
510
+ else
511
+ data = new Uint8Array(Buffer.from(item.data, "base64"));
473
512
  const imageType = (0, attachments_1.detectImageContentType)(data);
474
513
  const contentType = imageType ?? (item.contentType.startsWith("image/") ? "application/octet-stream" : item.contentType);
475
- return { kind: imageType ? "image" : "file", data, filename: (0, attachments_1.attachmentFilename)(item.filename), contentType };
476
- });
477
- (0, attachments_1.validateAttachments)(attachments);
514
+ attachments.push({ kind: imageType ? "image" : "file", data, filename: (0, attachments_1.attachmentFilename)(item.filename), contentType });
515
+ (0, attachments_1.validateAttachments)(attachments);
516
+ }
478
517
  return attachments;
479
518
  }
480
519
  async selectExpert(name) {
@@ -15,7 +15,7 @@ const session_history_1 = require("./session-history");
15
15
  const desktop_launch_1 = require("../desktop-launch");
16
16
  const desktop_launch_2 = require("./desktop-launch");
17
17
  exports.workbuddyManifest = {
18
- id: "workbuddy", version: "1.1.19", displayName: "WorkBuddy", type: "agent",
18
+ id: "workbuddy", version: "1.1.26", displayName: "WorkBuddy", type: "agent",
19
19
  entry: "dist/index.js", apiVersion: "1", process: "in-process",
20
20
  capabilities: ["chat", "health"], configSchema: "config.schema.json",
21
21
  };
@@ -236,8 +236,13 @@ class WorkbuddyPlugin {
236
236
  }
237
237
  finally {
238
238
  if (this.activeSessionId && this.desktopSessionValid && this.client?.conversationId) {
239
- this.activeDesktopConversationId = await this.client.conversationId();
240
- await this.persistKnownSessions();
239
+ try {
240
+ this.activeDesktopConversationId = await this.client.conversationId();
241
+ await this.persistKnownSessions();
242
+ }
243
+ catch (error) {
244
+ this.dailyPointsLogger?.warn("WorkBuddy 会话状态同步失败,已保留本轮回复", { stage: "完成后读取会话标识或保存映射", error: error instanceof Error ? error.message : String(error) });
245
+ }
241
246
  }
242
247
  }
243
248
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bridge-workbuddy",
3
- "version": "1.1.20",
3
+ "version": "1.1.27",
4
4
  "description": "mu-agent-bridge 的 WorkBuddy 桌面端插件。",
5
5
  "keywords": [
6
6
  "mu-agent-bridge-plugin",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "workbuddy",
3
- "version": "1.1.20",
3
+ "version": "1.1.27",
4
4
  "type": "agent",
5
5
  "displayName": "WorkBuddy",
6
6
  "entry": "dist/src-ts/plugins/workbuddy/index.js",