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.
- package/dist/src-ts/plugins/qwenwork/cdp-client.js +41 -2
- package/dist/src-ts/plugins/qwenwork/index.js +6 -39
- package/dist/src-ts/plugins/workbuddy/cdp-client.js +54 -15
- package/dist/src-ts/plugins/workbuddy/index.js +8 -3
- package/dist/tests-ts/qwenwork-plugin.test.js +45 -47
- package/dist/tests-ts/workbuddy-plugin.test.js +80 -2
- package/package.json +1 -1
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +41 -2
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +6 -39
- package/plugins/qwenwork/package.json +1 -1
- package/plugins/qwenwork/plugin.json +1 -1
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +54 -15
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +8 -3
- package/plugins/workbuddy/package.json +1 -1
- package/plugins/workbuddy/plugin.json +1 -1
|
@@ -261,7 +261,34 @@ class QwenCdpClient {
|
|
|
261
261
|
return { text: reply, ...(attachments.length ? { attachments } : {}) };
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
-
|
|
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
|
-
|
|
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.
|
|
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" ||
|
|
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
|
-
|
|
303
|
-
|
|
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.
|
|
378
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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 =
|
|
472
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
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)("
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)("
|
|
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, "
|
|
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
|
|
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
|
-
|
|
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
|
@@ -261,7 +261,34 @@ class QwenCdpClient {
|
|
|
261
261
|
return { text: reply, ...(attachments.length ? { attachments } : {}) };
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
-
|
|
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
|
-
|
|
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.
|
|
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" ||
|
|
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
|
-
|
|
303
|
-
|
|
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.
|
|
378
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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 =
|
|
472
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
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
|
});
|