mu-agent-bridge 1.0.34 → 1.0.39
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/README.md +4 -2
- package/dist/src-ts/channel-sessions.js +90 -24
- package/dist/src-ts/control-server.js +48 -7
- package/dist/src-ts/dsh-agent.js +141 -46
- package/dist/src-ts/headless-account.patch.yml +8 -1
- package/dist/src-ts/plugin-manager.js +69 -3
- package/dist/src-ts/plugins/aun/attachments.js +44 -13
- package/dist/src-ts/plugins/aun/index.js +5 -6
- package/dist/src-ts/plugins/chatgpt/cdp-client.js +342 -9
- package/dist/src-ts/plugins/chatgpt/index.js +127 -7
- package/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/dist/src-ts/plugins/doubao/attachments.js +42 -0
- package/dist/src-ts/plugins/doubao/cdp-client.js +530 -48
- package/dist/src-ts/plugins/doubao/index.js +114 -25
- package/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/dist/src-ts/plugins/dsh/index.js +18 -2
- package/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/dist/src-ts/plugins/qwenwork/cdp-client.js +271 -10
- package/dist/src-ts/plugins/qwenwork/index.js +42 -3
- package/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/dist/src-ts/plugins/workbuddy/cdp-client.js +42 -2
- package/dist/src-ts/plugins/workbuddy/index.js +23 -3
- package/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/dist/src-ts/session-archive.js +509 -0
- package/dist/src-ts/session-history.js +2 -0
- package/dist/src-ts/session-management.js +665 -0
- package/dist/src-ts/session-summary.js +171 -0
- package/dist/tests-ts/aun-attachments.test.js +41 -3
- package/dist/tests-ts/aun-plugin.test.js +28 -5
- package/dist/tests-ts/bundled-package.test.js +3 -1
- package/dist/tests-ts/channel-sessions.test.js +255 -2
- package/dist/tests-ts/chatgpt-history.test.js +197 -0
- package/dist/tests-ts/chatgpt-plugin.test.js +279 -2
- package/dist/tests-ts/control-server.test.js +39 -0
- package/dist/tests-ts/desktop-launch.test.js +41 -0
- package/dist/tests-ts/doubao-history.test.js +172 -0
- package/dist/tests-ts/doubao-plugin.test.js +549 -2
- package/dist/tests-ts/dsh-agent.test.js +199 -0
- package/dist/tests-ts/dsh-history.test.js +165 -0
- package/dist/tests-ts/native-session-management.test.js +195 -0
- package/dist/tests-ts/plugin-manager.test.js +354 -4
- package/dist/tests-ts/qwenwork-history.test.js +339 -0
- package/dist/tests-ts/qwenwork-images.test.js +166 -0
- package/dist/tests-ts/qwenwork-plugin.test.js +40 -1
- package/dist/tests-ts/session-archive.test.js +422 -0
- package/dist/tests-ts/session-management.test.js +323 -0
- package/dist/tests-ts/session-summary.test.js +199 -0
- package/dist/tests-ts/workbuddy-history.test.js +256 -0
- package/dist/tests-ts/workbuddy-plugin.test.js +43 -0
- package/package.json +1 -1
- package/plugins/aun/dist/src-ts/plugins/aun/attachments.js +44 -13
- package/plugins/aun/dist/src-ts/plugins/aun/index.js +5 -6
- package/plugins/aun/package.json +1 -1
- package/plugins/aun/plugin.json +1 -1
- package/plugins/chatgpt/dist/src-ts/attachments.js +62 -0
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +342 -9
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +127 -7
- package/plugins/chatgpt/package.json +1 -1
- package/plugins/chatgpt/plugin.json +14 -1
- package/plugins/doubao/config.schema.json +3 -0
- package/plugins/doubao/dist/src-ts/attachments.js +62 -0
- package/plugins/doubao/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/plugins/doubao/dist/src-ts/plugins/doubao/attachments.js +42 -0
- package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +530 -48
- package/plugins/doubao/dist/src-ts/plugins/doubao/index.js +114 -25
- package/plugins/doubao/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/plugins/doubao/package.json +1 -1
- package/plugins/doubao/plugin.json +1 -1
- package/plugins/dsh/config.schema.json +2 -1
- package/plugins/dsh/dist/src-ts/attachments.js +62 -0
- package/plugins/dsh/dist/src-ts/dsh-agent.js +141 -46
- package/plugins/dsh/dist/src-ts/headless-account.patch.yml +8 -1
- package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +18 -2
- package/plugins/dsh/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/plugins/dsh/package.json +5 -2
- package/plugins/dsh/plugin.json +1 -1
- package/plugins/qwenwork/dist/src-ts/attachments.js +62 -0
- package/plugins/qwenwork/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +271 -10
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +42 -3
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/plugins/qwenwork/package.json +1 -1
- package/plugins/qwenwork/plugin.json +1 -1
- package/plugins/workbuddy/config.schema.json +1 -1
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +42 -2
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +23 -3
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/plugins/workbuddy/package.json +1 -1
- package/plugins/workbuddy/plugin.json +1 -1
|
@@ -6,8 +6,12 @@ exports.verifyQwenHeartbeatReply = verifyQwenHeartbeatReply;
|
|
|
6
6
|
exports.isQwenHeartbeatReplyComplete = isQwenHeartbeatReplyComplete;
|
|
7
7
|
exports.isQwenApiUnavailableReply = isQwenApiUnavailableReply;
|
|
8
8
|
exports.isQwenBridgeHealthPrompt = isQwenBridgeHealthPrompt;
|
|
9
|
+
exports.normalizeQwenImageUrl = normalizeQwenImageUrl;
|
|
10
|
+
exports.decodeQwenDataImage = decodeQwenDataImage;
|
|
11
|
+
exports.parseQwenFinalFileDetails = parseQwenFinalFileDetails;
|
|
9
12
|
const node_crypto_1 = require("node:crypto");
|
|
10
13
|
const cdp_draft_1 = require("../cdp-draft");
|
|
14
|
+
const attachments_1 = require("../../attachments");
|
|
11
15
|
exports.assistantMessageSelector = '[data-message-id][data-part-type="text"]:not([data-conversation-user-text="true"])';
|
|
12
16
|
exports.inputSelector = '.chat-input-editor-text[contenteditable="true"]';
|
|
13
17
|
function freshAssistantMessages(beforeIds, messages) {
|
|
@@ -28,6 +32,57 @@ function isQwenApiUnavailableReply(reply) {
|
|
|
28
32
|
function isQwenBridgeHealthPrompt(text) {
|
|
29
33
|
return /^这是后台健康检测。请只回复 MAB_QWEN_[A-Z0-9]+,不要添加任何其它文字。$/.test(text.trim());
|
|
30
34
|
}
|
|
35
|
+
/** 千问返回的 file://D:%5C... 链接不是标准 URL,统一转换为本地文件 URL。 */
|
|
36
|
+
function normalizeQwenImageUrl(address) {
|
|
37
|
+
if (typeof address !== "string" || !address.toLowerCase().startsWith("file://"))
|
|
38
|
+
return undefined;
|
|
39
|
+
const raw = address.slice(7);
|
|
40
|
+
let decoded;
|
|
41
|
+
try {
|
|
42
|
+
decoded = decodeURIComponent(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (/^\/\//.test(decoded) || /^[\\/]{2}/.test(decoded))
|
|
48
|
+
return undefined;
|
|
49
|
+
const path = decoded.replaceAll("\\", "/").replace(/\/{2,}/g, "/").replace(/^\/+/, "");
|
|
50
|
+
if (!/^[A-Za-z]:\//.test(path))
|
|
51
|
+
return undefined;
|
|
52
|
+
const url = new URL(`file:///${path}`);
|
|
53
|
+
if (url.hostname || url.search || url.hash)
|
|
54
|
+
return undefined;
|
|
55
|
+
return url.href;
|
|
56
|
+
}
|
|
57
|
+
function decodeQwenDataImage(address) {
|
|
58
|
+
const match = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/i.exec(address);
|
|
59
|
+
if (!match)
|
|
60
|
+
return undefined;
|
|
61
|
+
const data = new Uint8Array(Buffer.from(match[2], "base64"));
|
|
62
|
+
const contentType = (0, attachments_1.detectImageContentType)(data);
|
|
63
|
+
return contentType && contentType === match[1].toLowerCase() ? { data, contentType } : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** 只解析“已展示最终文件”工具自己的 JSON 参数,不读取消息正文中的路径。 */
|
|
66
|
+
function parseQwenFinalFileDetails(value) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(value);
|
|
69
|
+
if (!Array.isArray(parsed.files))
|
|
70
|
+
return [];
|
|
71
|
+
return parsed.files.flatMap((item) => {
|
|
72
|
+
if (typeof item?.file_path !== "string")
|
|
73
|
+
return [];
|
|
74
|
+
const path = item.file_path.trim();
|
|
75
|
+
if (!/^[A-Za-z]:[\\/][^\0]*$/.test(path))
|
|
76
|
+
return [];
|
|
77
|
+
const filename = path.replaceAll("\\", "/").split("/").pop() ?? "";
|
|
78
|
+
const address = normalizeQwenImageUrl(`file://${path}`);
|
|
79
|
+
return address && filename ? [{ address, filename }] : [];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
31
86
|
function wait(ms, signal) {
|
|
32
87
|
if (signal?.aborted)
|
|
33
88
|
return Promise.reject(new Error("请求已取消"));
|
|
@@ -41,6 +96,7 @@ function wait(ms, signal) {
|
|
|
41
96
|
}
|
|
42
97
|
/** 千问桌面客户端的 CDP/tRPC 通信。 */
|
|
43
98
|
class QwenCdpClient {
|
|
99
|
+
attachmentInput = true;
|
|
44
100
|
socket;
|
|
45
101
|
sequence = 0;
|
|
46
102
|
pending = new Map();
|
|
@@ -145,6 +201,10 @@ class QwenCdpClient {
|
|
|
145
201
|
});
|
|
146
202
|
}
|
|
147
203
|
async direct(text, timeoutMs = 240_000, signal, expectedReply) {
|
|
204
|
+
return (await this.complete(text, timeoutMs, signal, expectedReply)).text;
|
|
205
|
+
}
|
|
206
|
+
async complete(text, timeoutMs = 240_000, signal, expectedReply, attachments) {
|
|
207
|
+
(0, attachments_1.validateAttachments)(attachments);
|
|
148
208
|
if (expectedReply) {
|
|
149
209
|
const draft = await this.evaluate(`document.querySelector(${JSON.stringify(exports.inputSelector)})?.innerText ?? ''`);
|
|
150
210
|
if (typeof draft === "string" && draft.trim() && !isQwenBridgeHealthPrompt(draft))
|
|
@@ -152,7 +212,7 @@ class QwenCdpClient {
|
|
|
152
212
|
}
|
|
153
213
|
await this.ensureVisible();
|
|
154
214
|
await this.recoverDraft();
|
|
155
|
-
const ready = await this.evaluate(`(() => { const input = document.querySelector(${JSON.stringify(exports.inputSelector)}); return Boolean(input && !input.innerText.trim() && !document.querySelector('button[aria-label="停止生成"],button[aria-label="Stop generating"]')); })()`);
|
|
215
|
+
const ready = await this.evaluate(`(() => { const input = document.querySelector(${JSON.stringify(exports.inputSelector)}); return Boolean(input && !input.innerText.trim() && !document.querySelector('button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]')); })()`);
|
|
156
216
|
if (!ready)
|
|
157
217
|
throw new Error("千问输入框含有未发送内容或正在生成回复,未发送新消息");
|
|
158
218
|
const snapshot = await this.evaluate(`JSON.stringify([...document.querySelectorAll(${JSON.stringify(exports.assistantMessageSelector)})].map(element => ({ id: element.getAttribute('data-message-id'), text: element.innerText })))`);
|
|
@@ -166,11 +226,23 @@ class QwenCdpClient {
|
|
|
166
226
|
})()`);
|
|
167
227
|
if (!focused)
|
|
168
228
|
throw new Error("千问输入框未找到");
|
|
169
|
-
await this.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
229
|
+
const addedIds = attachments?.length ? await this.injectAttachments(attachments) : [];
|
|
230
|
+
try {
|
|
231
|
+
await this.cdp("Input.insertText", { text });
|
|
232
|
+
const sent = await this.evaluate(`(() => { const input = document.querySelector(${JSON.stringify(exports.inputSelector)}); const button = document.querySelector('button[aria-label="发送消息"]'); if (!input || input.innerText.trim() !== ${JSON.stringify(text.trim())} || !button || button.disabled || button.getAttribute('aria-disabled') === 'true') return false; button.click(); return true; })()`);
|
|
233
|
+
if (sent !== true)
|
|
234
|
+
throw new Error("千问发送按钮未就绪或输入校验失败,未发送消息");
|
|
235
|
+
await this.waitFor(`(() => { const input = document.querySelector(${JSON.stringify(exports.inputSelector)}); return Boolean(input && !input.innerText.trim()); })()`, "千问未确认消息已提交");
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// CDP 断开时清理也可能失败,保留最初的发送错误。
|
|
239
|
+
try {
|
|
240
|
+
await this.cleanupAttachments(addedIds);
|
|
241
|
+
}
|
|
242
|
+
catch { /* 清理失败不能覆盖原错。 */ }
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
const sessionUrl = String(await this.evaluate("location.href"));
|
|
174
246
|
let previousReply = "";
|
|
175
247
|
let stablePolls = 0;
|
|
176
248
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -183,11 +255,192 @@ class QwenCdpClient {
|
|
|
183
255
|
previousReply = reply;
|
|
184
256
|
if (reply && stablePolls >= 3 && isQwenApiUnavailableReply(reply))
|
|
185
257
|
throw new Error("千问生成接口不可用");
|
|
186
|
-
|
|
187
|
-
|
|
258
|
+
const generating = await this.evaluate(`Boolean(document.querySelector('button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]'))`);
|
|
259
|
+
if (!generating && (expectedReply ? isQwenHeartbeatReplyComplete(reply, expectedReply) && stablePolls >= 1 : reply && stablePolls >= 3)) {
|
|
260
|
+
const attachments = expectedReply ? [] : await this.readArtifacts(beforeIds, sessionUrl);
|
|
261
|
+
return { text: reply, ...(attachments.length ? { attachments } : {}) };
|
|
262
|
+
}
|
|
188
263
|
}
|
|
189
264
|
throw new Error("千问办公 UI 超时未返回回复");
|
|
190
265
|
}
|
|
266
|
+
async injectAttachments(items) {
|
|
267
|
+
const files = items.map(item => {
|
|
268
|
+
const original = (0, attachments_1.attachmentFilename)(item.filename);
|
|
269
|
+
const dot = original.lastIndexOf(".");
|
|
270
|
+
const suffix = `-mab-${(0, node_crypto_1.randomUUID)()}`;
|
|
271
|
+
const filename = dot > 0 ? `${original.slice(0, dot)}${suffix}${original.slice(dot)}` : `${original}${suffix}`;
|
|
272
|
+
return { filename, contentType: item.contentType, data: Buffer.from(item.data).toString("base64") };
|
|
273
|
+
});
|
|
274
|
+
const result = await this.evaluate(`(async () => {
|
|
275
|
+
const editor = document.querySelector(${JSON.stringify(exports.inputSelector)});
|
|
276
|
+
if (!editor) throw new Error('千问附件输入框未找到');
|
|
277
|
+
const key = Object.keys(editor).find(key => key.startsWith('__reactFiber'));
|
|
278
|
+
let props;
|
|
279
|
+
for (let fiber = editor[key]; fiber; fiber = fiber.return) {
|
|
280
|
+
if (typeof fiber.memoizedProps?.onAddAttachments === 'function') { props = fiber.memoizedProps; break; }
|
|
281
|
+
}
|
|
282
|
+
if (!props) throw new Error('千问当前页面不支持附件输入');
|
|
283
|
+
const existing = [...(props.files ?? []), ...(props.images ?? []), ...(props.slashAddedFiles ?? [])];
|
|
284
|
+
if (existing.length) throw new Error('千问输入框已有附件,未发送消息');
|
|
285
|
+
const cleanupAdded = (ids, names = []) => {
|
|
286
|
+
const currentEditor = document.querySelector(${JSON.stringify(exports.inputSelector)});
|
|
287
|
+
const currentKey = Object.keys(currentEditor ?? {}).find(key => key.startsWith('__reactFiber'));
|
|
288
|
+
let current;
|
|
289
|
+
for (let fiber = currentKey && currentEditor ? currentEditor[currentKey] : null; fiber; fiber = fiber.return) {
|
|
290
|
+
if (typeof fiber.memoizedProps?.onAddAttachments === 'function') { current = fiber.memoizedProps; break; }
|
|
291
|
+
}
|
|
292
|
+
if (!current) return;
|
|
293
|
+
for (const item of current.images ?? []) if (ids.includes(item.id) || names.includes(item.filename)) current.onRemoveImage?.(item.id);
|
|
294
|
+
for (const item of current.files ?? []) if (ids.includes(item.id) || names.includes(item.filename)) current.onRemoveFile?.(item.id);
|
|
295
|
+
};
|
|
296
|
+
const files = ${JSON.stringify(files)}.map(item => {
|
|
297
|
+
const binary = atob(item.data); const bytes = new Uint8Array(binary.length);
|
|
298
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
299
|
+
return new File([bytes], item.filename, { type: item.contentType });
|
|
300
|
+
});
|
|
301
|
+
let uploaded;
|
|
302
|
+
try { uploaded = await props.onAddAttachments(files); }
|
|
303
|
+
catch (error) {
|
|
304
|
+
try { cleanupAdded([], files.map(file => file.name)); } catch { /* 保留原生上传错误。 */ }
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
const added = [...(uploaded?.images ?? []), ...(uploaded?.files ?? [])];
|
|
308
|
+
const ids = added.map(item => item.id).filter(id => typeof id === 'string');
|
|
309
|
+
if (added.length !== files.length || ids.length !== files.length || added.some(item => item.isLoading)) {
|
|
310
|
+
try { cleanupAdded(ids, files.map(file => file.name)); } catch { /* 保留附件确认错误。 */ }
|
|
311
|
+
throw new Error('千问附件上传未确认,未发送消息');
|
|
312
|
+
}
|
|
313
|
+
return ids;
|
|
314
|
+
})()`);
|
|
315
|
+
if (!Array.isArray(result))
|
|
316
|
+
throw new Error("千问附件上传未确认,未发送消息");
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
async cleanupAttachments(ids) {
|
|
320
|
+
if (!ids.length)
|
|
321
|
+
return;
|
|
322
|
+
await this.evaluate(`(() => {
|
|
323
|
+
const editor = document.querySelector(${JSON.stringify(exports.inputSelector)}); if (!editor) return;
|
|
324
|
+
const key = Object.keys(editor).find(key => key.startsWith('__reactFiber')); const ids = ${JSON.stringify(ids)};
|
|
325
|
+
for (let fiber = editor[key]; fiber; fiber = fiber.return) {
|
|
326
|
+
const props = fiber.memoizedProps; if (typeof props?.onAddAttachments !== 'function') continue;
|
|
327
|
+
for (const item of props.images ?? []) if (ids.includes(item.id)) props.onRemoveImage?.(item.id);
|
|
328
|
+
for (const item of props.files ?? []) if (ids.includes(item.id)) props.onRemoveFile?.(item.id);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
})()`);
|
|
332
|
+
}
|
|
333
|
+
async readArtifacts(beforeIds, sessionUrl) {
|
|
334
|
+
const collectArtifacts = `(() => {
|
|
335
|
+
if (location.href !== ${JSON.stringify(sessionUrl)}) throw new Error('千问会话已切换,停止采集附件');
|
|
336
|
+
const before = new Set(${JSON.stringify([...beforeIds])}); const files = [];
|
|
337
|
+
for (const part of document.querySelectorAll(${JSON.stringify(exports.assistantMessageSelector)})) {
|
|
338
|
+
if (before.has(part.getAttribute('data-message-id'))) continue;
|
|
339
|
+
const message = part.closest?.('[data-assistant-message-id]') || part;
|
|
340
|
+
for (const image of (message.querySelectorAll?.('img[src^="data:image/"]') ?? [])) files.push({ address: image.getAttribute('src'), filename: image.getAttribute('alt') || '' });
|
|
341
|
+
for (const button of (message.querySelectorAll?.('button') ?? [])) {
|
|
342
|
+
if (!button.innerText.includes('已展示最终文件')) continue;
|
|
343
|
+
for (const detail of (button.parentElement?.parentElement?.querySelectorAll?.('pre[data-slot="tool-input-details"]') ?? [])) {
|
|
344
|
+
files.push({ details: detail.textContent || '' });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return JSON.stringify(files);
|
|
349
|
+
})()`;
|
|
350
|
+
// 工具区域由 React 异步展开,点击和读取必须分开执行。
|
|
351
|
+
await this.evaluate(`(() => {
|
|
352
|
+
if (location.href !== ${JSON.stringify(sessionUrl)}) throw new Error('千问会话已切换,停止采集附件');
|
|
353
|
+
const before = new Set(${JSON.stringify([...beforeIds])});
|
|
354
|
+
for (const part of document.querySelectorAll(${JSON.stringify(exports.assistantMessageSelector)})) {
|
|
355
|
+
if (before.has(part.getAttribute('data-message-id'))) continue;
|
|
356
|
+
const message = part.closest?.('[data-assistant-message-id]') || part;
|
|
357
|
+
for (const button of (message.querySelectorAll?.('button') ?? [])) {
|
|
358
|
+
if (button.innerText.includes('已展示最终文件') && button.getAttribute('aria-expanded') !== 'true') button.click();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
})()`);
|
|
363
|
+
let encoded = "[]";
|
|
364
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
365
|
+
encoded = String(await this.evaluateArtifactResult(collectArtifacts, sessionUrl) ?? "[]");
|
|
366
|
+
if (encoded !== "[]")
|
|
367
|
+
break;
|
|
368
|
+
await wait(250);
|
|
369
|
+
}
|
|
370
|
+
const files = [...new Map(JSON.parse(encoded || "[]")
|
|
371
|
+
.flatMap((item) => item.details ? parseQwenFinalFileDetails(item.details) : [item])
|
|
372
|
+
.map((item) => [item.address, item]).filter(([address]) => typeof address === "string")).values()];
|
|
373
|
+
const dataImages = files.map((item) => ({ ...item, image: decodeQwenDataImage(item.address) })).filter((item) => Boolean(item.image));
|
|
374
|
+
const addresses = files.map((item) => normalizeQwenImageUrl(item.address)).filter((value) => Boolean(value)).slice(0, Math.max(0, attachments_1.MAX_ATTACHMENTS - dataImages.length));
|
|
375
|
+
const attachments = dataImages.map((item) => ({ kind: "image", data: item.image.data, filename: (0, attachments_1.attachmentFilename)(item.filename || `qwen-${(0, node_crypto_1.randomUUID)()}.jpg`), contentType: item.image.contentType }));
|
|
376
|
+
if (!addresses.length) {
|
|
377
|
+
(0, attachments_1.validateAttachments)(attachments);
|
|
378
|
+
return attachments;
|
|
379
|
+
}
|
|
380
|
+
const downloaded = await this.evaluateArtifactResult(`(async () => {
|
|
381
|
+
if (location.href !== ${JSON.stringify(sessionUrl)}) throw new Error('千问会话已切换,停止采集附件');
|
|
382
|
+
const urls = ${JSON.stringify(addresses)}; const result = []; let total = 0;
|
|
383
|
+
for (const url of urls) {
|
|
384
|
+
const response = await fetch(url); if (!response.ok || !response.body) continue;
|
|
385
|
+
const reader = response.body.getReader(); const chunks = []; let size = 0;
|
|
386
|
+
try { for (;;) { const item = await reader.read(); if (item.done) break; size += item.value.byteLength; total += item.value.byteLength; if (size > ${attachments_1.MAX_ATTACHMENT_BYTES} || total > ${attachments_1.MAX_TOTAL_ATTACHMENT_BYTES}) throw new Error('千问附件过大'); chunks.push(item.value); } }
|
|
387
|
+
finally { reader.releaseLock(); }
|
|
388
|
+
const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
389
|
+
let binary = ''; for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
390
|
+
result.push({ url, data: btoa(binary), contentType: response.headers.get('content-type')?.split(';')[0] || '' });
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
})()`, sessionUrl);
|
|
394
|
+
for (const item of downloaded) {
|
|
395
|
+
const data = new Uint8Array(Buffer.from(item.data, "base64"));
|
|
396
|
+
const contentType = (0, attachments_1.detectImageContentType)(data);
|
|
397
|
+
const filename = (0, attachments_1.attachmentFilename)(decodeURIComponent(new URL(item.url).pathname.split("/").pop() || `qwen-${(0, node_crypto_1.randomUUID)()}.jpg`));
|
|
398
|
+
const mime = contentType ?? (item.contentType.startsWith("image/") ? "application/octet-stream" : item.contentType || "application/octet-stream");
|
|
399
|
+
attachments.push({ kind: contentType ? "image" : "file", data, filename, contentType: mime });
|
|
400
|
+
}
|
|
401
|
+
const unique = new Map();
|
|
402
|
+
for (const item of attachments) {
|
|
403
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(item.data).digest("hex");
|
|
404
|
+
unique.set(`${item.kind}:${hash}`, item);
|
|
405
|
+
}
|
|
406
|
+
attachments.splice(0, attachments.length, ...unique.values());
|
|
407
|
+
(0, attachments_1.validateAttachments)(attachments);
|
|
408
|
+
return attachments;
|
|
409
|
+
}
|
|
410
|
+
/** 将页面结果留在本轮临时缓存,避免图片或视频超过 CDP 单条响应上限。 */
|
|
411
|
+
async evaluateArtifactResult(expression, sessionUrl) {
|
|
412
|
+
const cacheKey = `__mab_qwen_artifacts_${(0, node_crypto_1.randomUUID)()}`;
|
|
413
|
+
const maxLength = Math.ceil(attachments_1.MAX_TOTAL_ATTACHMENT_BYTES / 3) * 4 + 1_048_576;
|
|
414
|
+
try {
|
|
415
|
+
const length = await this.evaluate(`(async () => {
|
|
416
|
+
if (location.href !== ${JSON.stringify(sessionUrl)}) throw new Error('千问会话已切换,停止采集附件');
|
|
417
|
+
const encoded = JSON.stringify(await (${expression}));
|
|
418
|
+
if (typeof encoded !== 'string' || encoded.length > ${maxLength}) throw new Error('千问附件结果过大或无效');
|
|
419
|
+
window[${JSON.stringify(cacheKey)}] = encoded;
|
|
420
|
+
return encoded.length;
|
|
421
|
+
})()`);
|
|
422
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > maxLength)
|
|
423
|
+
throw new Error("千问附件编码长度无效");
|
|
424
|
+
const parts = [];
|
|
425
|
+
for (let offset = 0; offset < length; offset += 262_144) {
|
|
426
|
+
const end = Math.min(offset + 262_144, length);
|
|
427
|
+
const part = await this.evaluate(`(() => {
|
|
428
|
+
if (location.href !== ${JSON.stringify(sessionUrl)}) throw new Error('千问会话已切换,停止采集附件');
|
|
429
|
+
return window[${JSON.stringify(cacheKey)}]?.slice(${offset}, ${end});
|
|
430
|
+
})()`);
|
|
431
|
+
if (typeof part !== "string" || part.length !== end - offset)
|
|
432
|
+
throw new Error("千问附件分块读取不完整");
|
|
433
|
+
parts.push(part);
|
|
434
|
+
}
|
|
435
|
+
return JSON.parse(parts.join(""));
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
try {
|
|
439
|
+
await this.evaluate(`delete window[${JSON.stringify(cacheKey)}]`);
|
|
440
|
+
}
|
|
441
|
+
catch { /* 断连时保留原始错误。 */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
191
444
|
onMessage(raw) {
|
|
192
445
|
let message;
|
|
193
446
|
try {
|
|
@@ -206,13 +459,13 @@ class QwenCdpClient {
|
|
|
206
459
|
}
|
|
207
460
|
async ensureVisible() {
|
|
208
461
|
await this.evaluate(`(async () => {
|
|
209
|
-
if (document.querySelector('button[aria-label="停止生成"],button[aria-label="Stop generating"]')) throw new Error('千问桌面端正在生成回复');
|
|
462
|
+
if (document.querySelector('button[aria-label="停止"],button[aria-label="停止生成"],button[aria-label="Stop generating"]')) throw new Error('千问桌面端正在生成回复');
|
|
210
463
|
// 隐藏窗口会暂停消息渲染,使桌面端的提交前检查一直等不到确认。
|
|
211
464
|
if (document.hidden && typeof window.desktopApi?.focusMainWindow === 'function') await window.desktopApi.focusMainWindow();
|
|
212
465
|
})()`);
|
|
213
466
|
}
|
|
214
467
|
recoverDraft() {
|
|
215
|
-
return (0, cdp_draft_1.recoverCdpDraft)(this.evaluate.bind(this), exports.inputSelector, 'button[aria-label="停止生成"],button[aria-label="Stop generating"]', this.options.dataDir);
|
|
468
|
+
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);
|
|
216
469
|
}
|
|
217
470
|
async cdp(method, params = {}, timeoutMs = this.options.timeoutMs) {
|
|
218
471
|
if (!this.socket)
|
|
@@ -259,6 +512,14 @@ class QwenCdpClient {
|
|
|
259
512
|
}
|
|
260
513
|
throw new Error(`tRPC ${path} 超时`);
|
|
261
514
|
}
|
|
515
|
+
/** 仅开放已核实的会话历史只读查询,不允许从历史 provider 调用 mutation。 */
|
|
516
|
+
async queryHistory(path, input) {
|
|
517
|
+
const result = await this.trpc(path, "query", input);
|
|
518
|
+
const envelope = result;
|
|
519
|
+
if (envelope?.result?.type === "data")
|
|
520
|
+
return envelope.result.data?.json;
|
|
521
|
+
throw new Error(envelope?.result?.error?.json?.message ?? `千问历史查询 ${path} 失败`);
|
|
522
|
+
}
|
|
262
523
|
async waitFor(expression, message) {
|
|
263
524
|
const deadline = Date.now() + this.options.timeoutMs;
|
|
264
525
|
while (Date.now() < deadline) {
|
|
@@ -8,10 +8,11 @@ const openai_adapter_1 = require("./openai-adapter");
|
|
|
8
8
|
const cdp_client_1 = require("./cdp-client");
|
|
9
9
|
const plugin_1 = require("../../plugin");
|
|
10
10
|
const desktop_launch_1 = require("../desktop-launch");
|
|
11
|
+
const session_history_1 = require("./session-history");
|
|
11
12
|
const CHAT_HEARTBEAT_INTERVAL_MS = 2 * 60 * 60_000;
|
|
12
13
|
exports.qwenworkManifest = {
|
|
13
14
|
id: "qwenwork",
|
|
14
|
-
version: "1.0.
|
|
15
|
+
version: "1.0.20",
|
|
15
16
|
displayName: "千问办公",
|
|
16
17
|
entry: "dist/index.js",
|
|
17
18
|
apiVersion: "1",
|
|
@@ -26,6 +27,8 @@ function asNumber(value, fallback) {
|
|
|
26
27
|
/** 千问插件由后台守护,所有通道共用请求队列和恢复流程。 */
|
|
27
28
|
class QwenworkPlugin {
|
|
28
29
|
manifest = exports.qwenworkManifest;
|
|
30
|
+
sessionHistory;
|
|
31
|
+
get attachmentInput() { return Boolean(this.relayTarget || this.client?.attachmentInput); }
|
|
29
32
|
sessionOperations = [];
|
|
30
33
|
injectedClient;
|
|
31
34
|
client;
|
|
@@ -80,6 +83,15 @@ class QwenworkPlugin {
|
|
|
80
83
|
this.callPlugin = context.callPlugin;
|
|
81
84
|
this.pluginStatus = context.pluginStatus;
|
|
82
85
|
this.activeCdpPort = (0, plugin_1.resolveRuntimePort)(config, context.runtime, "qwenwork", "QWEN_CDP_PORT", this.configuredCdpPort ?? exports.qwenworkManifest.defaults.cdpPort);
|
|
86
|
+
this.sessionHistory = new session_history_1.QwenworkSessionHistoryProvider({
|
|
87
|
+
scope: `qwenwork:cdp:${this.activeCdpPort}`,
|
|
88
|
+
unavailableReason: () => this.relayTarget ? "千问办公中继模式不支持原生历史读取" : this.busy ? "千问办公当前忙,暂不读取历史" : this.recovery ? "千问办公正在恢复,暂不读取历史" : this.client && !this.client.connected() ? "千问办公当前离线,暂不读取历史" : this.state !== "ready" ? "千问办公当前未就绪,暂不读取历史" : "",
|
|
89
|
+
query: async (path, input) => {
|
|
90
|
+
if (!this.client?.queryHistory)
|
|
91
|
+
throw new Error("当前千问客户端没有只读会话查询能力");
|
|
92
|
+
return this.client.queryHistory(path, input);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
83
95
|
if (!this.relayTarget) {
|
|
84
96
|
const client = this.injectedClient ?? new cdp_client_1.QwenCdpClient({
|
|
85
97
|
port: this.activeCdpPort,
|
|
@@ -171,8 +183,7 @@ class QwenworkPlugin {
|
|
|
171
183
|
await this.recovery;
|
|
172
184
|
if (this.state === "stopped" || (!this.relayTarget && !this.client))
|
|
173
185
|
throw new Error("qwenwork 插件未就绪");
|
|
174
|
-
|
|
175
|
-
return { text };
|
|
186
|
+
return this.enqueueTask(() => this.completeRequest(request));
|
|
176
187
|
}
|
|
177
188
|
async controlSession(request) {
|
|
178
189
|
if (request.operation !== "new")
|
|
@@ -218,6 +229,34 @@ class QwenworkPlugin {
|
|
|
218
229
|
throw error;
|
|
219
230
|
}
|
|
220
231
|
}
|
|
232
|
+
async completeRequest(request) {
|
|
233
|
+
if (this.relayTarget) {
|
|
234
|
+
return this.callPlugin(this.relayTarget, request);
|
|
235
|
+
}
|
|
236
|
+
if (!this.client)
|
|
237
|
+
throw new Error("qwenwork 插件未就绪");
|
|
238
|
+
try {
|
|
239
|
+
if (!this.client.connected())
|
|
240
|
+
throw new Error("千问 CDP 已断开,等待空闲后自动恢复");
|
|
241
|
+
if (request.attachments?.length && (!this.client.attachmentInput || !this.client.complete))
|
|
242
|
+
throw new Error("千问当前客户端不支持附件输入");
|
|
243
|
+
const response = this.client.complete
|
|
244
|
+
? await this.client.complete(request.task, request.timeoutMs ?? 240_000, undefined, undefined, request.attachments)
|
|
245
|
+
: { text: await this.complete(request.task, request.timeoutMs ?? 240_000) };
|
|
246
|
+
if ((0, cdp_client_1.isQwenApiUnavailableReply)(response.text))
|
|
247
|
+
throw new Error("千问生成接口不可用");
|
|
248
|
+
this.failures = 0;
|
|
249
|
+
this.state = "ready";
|
|
250
|
+
this.message = "";
|
|
251
|
+
return response;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.message = error instanceof Error ? error.message : String(error);
|
|
255
|
+
this.failures = this.client.connected() ? 0 : 2;
|
|
256
|
+
this.state = "degraded";
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
221
260
|
registerRoutes(http) {
|
|
222
261
|
if (!this.adapter)
|
|
223
262
|
return;
|