mu-agent-bridge 1.0.30 → 1.0.32
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 +17 -1
- package/dist/installer/mu-agent-bridge.msi +0 -0
- package/dist/src-ts/agent-availability.js +14 -0
- package/dist/src-ts/channel-sessions.js +28 -5
- package/dist/src-ts/cli-commands.js +6 -2
- package/dist/src-ts/control-server.js +13 -2
- package/dist/src-ts/daemon-process.js +3 -2
- package/dist/src-ts/dsh-agent.js +4 -2
- package/dist/src-ts/plugin-manager.js +53 -3
- package/dist/src-ts/plugins/aun/index.js +52 -7
- package/dist/src-ts/plugins/aun/public/index.html +1 -1
- package/dist/src-ts/plugins/chatgpt/cdp-client.js +31 -2
- package/dist/src-ts/plugins/chatgpt/index.js +37 -1
- package/dist/src-ts/plugins/chatgpt/session-store.js +6 -2
- package/dist/src-ts/plugins/desktop-launch.js +13 -2
- package/dist/src-ts/plugins/doubao/cdp-client.js +29 -3
- package/dist/src-ts/plugins/dsh/index.js +1 -0
- package/dist/src-ts/plugins/feishu/index.js +35 -10
- package/dist/src-ts/plugins/feishu/public/app.js +20 -8
- package/dist/src-ts/plugins/feishu/public/index.html +1 -1
- package/dist/src-ts/plugins/qwenwork/cdp-client.js +105 -31
- package/dist/src-ts/plugins/qwenwork/index.js +201 -7
- package/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
- package/dist/src-ts/plugins/workbuddy/cdp-client.js +10 -4
- package/dist/src-ts/plugins/workbuddy/index.js +139 -7
- package/dist/src-ts/secret-input.js +47 -0
- package/dist/tests-ts/agent-availability.test.js +27 -0
- package/dist/tests-ts/aun-default-owner-page.test.js +81 -0
- package/dist/tests-ts/aun-plugin.test.js +152 -8
- package/dist/tests-ts/channel-sessions.test.js +189 -0
- package/dist/tests-ts/chatgpt-plugin.test.js +104 -0
- package/dist/tests-ts/cli-commands.test.js +58 -0
- package/dist/tests-ts/control-server.test.js +51 -0
- package/dist/tests-ts/daemon-process.test.js +1 -0
- package/dist/tests-ts/desktop-launch.test.js +40 -0
- package/dist/tests-ts/doubao-plugin.test.js +10 -0
- package/dist/tests-ts/dsh-agent.test.js +25 -0
- package/dist/tests-ts/feishu-page.test.js +10 -3
- package/dist/tests-ts/feishu-plugin.test.js +62 -1
- package/dist/tests-ts/installer-assets.test.js +84 -0
- package/dist/tests-ts/plugin-manager.test.js +170 -2
- package/dist/tests-ts/qwenwork-plugin.test.js +443 -0
- package/dist/tests-ts/qwenwork-sse.test.js +46 -0
- package/dist/tests-ts/workbuddy-plugin.test.js +278 -3
- package/package.json +2 -1
- package/plugins/aun/config.schema.json +1 -0
- package/plugins/aun/dist/src-ts/plugins/aun/index.js +52 -7
- package/plugins/aun/dist/src-ts/plugins/aun/public/index.html +1 -1
- package/plugins/aun/package.json +1 -1
- package/plugins/aun/plugin.json +1 -1
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +31 -2
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +37 -1
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/session-store.js +6 -2
- package/plugins/chatgpt/package.json +1 -1
- package/plugins/chatgpt/plugin.json +1 -1
- package/plugins/doubao/dist/src-ts/plugins/desktop-launch.js +13 -2
- package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +29 -3
- package/plugins/dsh/dist/src-ts/dsh-agent.js +4 -2
- package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +1 -0
- package/plugins/dsh/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
- package/plugins/feishu/dist/src-ts/plugins/feishu/index.js +35 -10
- package/plugins/feishu/dist/src-ts/plugins/feishu/public/app.js +20 -8
- package/plugins/feishu/dist/src-ts/plugins/feishu/public/index.html +1 -1
- package/plugins/qwenwork/config.schema.json +3 -0
- package/plugins/qwenwork/dist/src-ts/plugins/desktop-launch.js +13 -2
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +105 -31
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +201 -7
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
- package/plugins/qwenwork/package.json +1 -1
- package/plugins/qwenwork/plugin.json +2 -2
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +10 -4
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +139 -7
- package/plugins/workbuddy/plugin.json +2 -2
|
@@ -17,6 +17,7 @@ class ChatgptSessionStore {
|
|
|
17
17
|
endpoint;
|
|
18
18
|
sessions = [];
|
|
19
19
|
bindings = {};
|
|
20
|
+
pendingBindings = {};
|
|
20
21
|
updatedAt = "";
|
|
21
22
|
writes = Promise.resolve();
|
|
22
23
|
constructor(directory, endpoint) {
|
|
@@ -31,10 +32,13 @@ class ChatgptSessionStore {
|
|
|
31
32
|
if (value.version !== 1 || value.endpoint !== this.endpoint || !Array.isArray(value.sessions)
|
|
32
33
|
|| !value.sessions.every(validSession) || typeof value.updatedAt !== "string"
|
|
33
34
|
|| !value.bindings || typeof value.bindings !== "object" || Array.isArray(value.bindings)
|
|
34
|
-
|| !Object.values(value.bindings).every(validSession)
|
|
35
|
+
|| !Object.values(value.bindings).every(validSession)
|
|
36
|
+
|| (value.pendingBindings !== undefined && (!value.pendingBindings || typeof value.pendingBindings !== "object" || Array.isArray(value.pendingBindings)
|
|
37
|
+
|| !Object.values(value.pendingBindings).every(ids => Array.isArray(ids) && ids.every(id => typeof id === "string")))))
|
|
35
38
|
return;
|
|
36
39
|
this.sessions = value.sessions.slice(0, 20);
|
|
37
40
|
this.bindings = Object.fromEntries(Object.entries(value.bindings));
|
|
41
|
+
this.pendingBindings = Object.fromEntries(Object.entries(value.pendingBindings ?? {}));
|
|
38
42
|
this.updatedAt = value.updatedAt;
|
|
39
43
|
}
|
|
40
44
|
catch (error) {
|
|
@@ -68,7 +72,7 @@ class ChatgptSessionStore {
|
|
|
68
72
|
return exact.length ? exact : this.sessions.filter(item => `${item.title}\n${item.lastUserMessage ?? ""}`.toLocaleLowerCase().includes(text));
|
|
69
73
|
}
|
|
70
74
|
save() {
|
|
71
|
-
const snapshot = { version: 1, endpoint: this.endpoint, updatedAt: this.updatedAt, sessions: this.sessions, bindings: this.bindings };
|
|
75
|
+
const snapshot = { version: 1, endpoint: this.endpoint, updatedAt: this.updatedAt, sessions: this.sessions, bindings: this.bindings, pendingBindings: this.pendingBindings };
|
|
72
76
|
const text = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
73
77
|
const write = this.writes.catch(() => { }).then(async () => {
|
|
74
78
|
if (!this.directory)
|
|
@@ -9,8 +9,17 @@ const node_fs_1 = require("node:fs");
|
|
|
9
9
|
const node_path_1 = require("node:path");
|
|
10
10
|
/** 在 Windows 常见安装目录中查找桌面程序,避免依赖单一盘符。 */
|
|
11
11
|
async function findWindowsExecutable(fileName, configured) {
|
|
12
|
-
if (configured?.trim())
|
|
13
|
-
|
|
12
|
+
if (configured?.trim()) {
|
|
13
|
+
const configuredPath = (0, node_path_1.resolve)(configured);
|
|
14
|
+
// 千问升级会保留或删除旧版本目录,从同一安装位置重新定位新版。
|
|
15
|
+
if (fileName.toLowerCase() === "qwenworkcn.exe" && /^v?\d+(?:[.-]\d+)+$/i.test((0, node_path_1.basename)((0, node_path_1.dirname)(configuredPath)))) {
|
|
16
|
+
const latest = await findExecutableInTree((0, node_path_1.dirname)((0, node_path_1.dirname)(configuredPath)), fileName, /^/, 1);
|
|
17
|
+
if (latest)
|
|
18
|
+
return latest;
|
|
19
|
+
}
|
|
20
|
+
if ((0, node_fs_1.existsSync)(configuredPath))
|
|
21
|
+
return configuredPath;
|
|
22
|
+
}
|
|
14
23
|
if (process.platform !== "win32")
|
|
15
24
|
return undefined;
|
|
16
25
|
const pattern = /doubao|豆包|qwen|qwwork|千问/i;
|
|
@@ -38,6 +47,8 @@ async function search(directory, fileName, pattern, depth, insideProduct) {
|
|
|
38
47
|
return (0, node_path_1.join)(directory, entry.name);
|
|
39
48
|
if (depth <= 0)
|
|
40
49
|
return undefined;
|
|
50
|
+
// 按数字比较版本目录,避免 1.2.5 排在 1.2.10 之前。
|
|
51
|
+
entries.sort((left, right) => right.name.localeCompare(left.name, "en", { numeric: true }));
|
|
41
52
|
for (const entry of entries)
|
|
42
53
|
if (entry.isDirectory() && !entry.name.startsWith(".") && (insideProduct || pattern.test(entry.name) || /^(?:Program Files(?: \(x86\))?|Programs)$/i.test(entry.name))) {
|
|
43
54
|
const found = await search((0, node_path_1.join)(directory, entry.name), fileName, pattern, depth - 1, insideProduct || pattern.test(entry.name));
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DoubaoCdpClient = exports.responseTextScript = void 0;
|
|
4
4
|
exports.doubaoStreamErrorMessage = doubaoStreamErrorMessage;
|
|
5
5
|
exports.requestBody = requestBody;
|
|
6
|
+
exports.mergeDoubaoReply = mergeDoubaoReply;
|
|
7
|
+
exports.cleanDoubaoReply = cleanDoubaoReply;
|
|
6
8
|
const node_crypto_1 = require("node:crypto");
|
|
7
9
|
function doubaoStreamErrorMessage(data) {
|
|
8
10
|
let code = "";
|
|
@@ -32,6 +34,29 @@ function requestBody(text, conversationId, botId, pluginList = []) {
|
|
|
32
34
|
return JSON.stringify({ client_meta: { conversation_id: conversationId, bot_id: botId, last_section_id: "", last_message_index: null, local_permissions: [{ permission_name: "ACCESS_COARSE_LOCATION", status: 1 }, { permission_name: "ACCESS_FINE_LOCATION", status: 1 }, { permission_name: "ACCESS_BACKGROUND_LOCATION", status: 1 }] }, messages: [{ local_message_id: (0, node_crypto_1.randomUUID)(), content_block: [block], message_status: 0 }], option: { send_message_scene: "", create_time_ms: Date.now(), collect_id: "", is_audio: false, answer_with_suggest: false, agent_mode: 2, tts_switch: false, need_deep_think: 0, click_clear_context: false, is_regen: false, is_replace: false, is_from_click_option: false, is_from_click_softlink: false, disable_sse_cache: false, scene_type: 0, unique_key: (0, node_crypto_1.randomUUID)(), start_seq: 0, need_create_conversation: false, regen_query_id: [], edit_query_id: [], message_from: 0, sse_recv_event_options: { support_chunk_delta: true }, support_lazy_fetch_stream: true, is_ai_playground: false, is_old_user: true, general_task_param: general, recovery_option: { is_recovery: false, req_create_time_sec: Math.floor(Date.now() / 1000), append_sse_event_scene: 0 }, message_storage_type: 0, related_deleted_message_ids: {}, connector_info_list: [], model_config: { model_item_key: "0", model_extra_params: {}, reasoning_effort: 5 }, aggregate_params: { mention_skill_list: "[]", mention_plugin_list: JSON.stringify(pluginList), mention_ext: "[{}]", conversation_mode: "", mode_id: "1", model_item_key: "0", agent_mode: "2", reasoning_effort: "5", provider_id: "" } }, user_context: [], ext: { agent_mode: "2", use_deep_think: "0", general_task_param: JSON.stringify(general), collection_id: "", is_finish: "1", commerce_credit_config_enable: "0" } });
|
|
33
35
|
}
|
|
34
36
|
exports.responseTextScript = `(value) => { const texts = []; const visit = (item) => { if (Array.isArray(item)) { for (const child of item) visit(child); return; } if (!item || typeof item !== 'object') return; if (item.text_block && typeof item.text_block.text === 'string') texts.push(item.text_block.text); for (const child of Object.values(item)) visit(child); }; visit(value); return texts; }`;
|
|
37
|
+
function mergeDoubaoReply(current, next) {
|
|
38
|
+
if (!next)
|
|
39
|
+
return current;
|
|
40
|
+
if (!current)
|
|
41
|
+
return next;
|
|
42
|
+
if (current.includes(next))
|
|
43
|
+
return current;
|
|
44
|
+
if (next.includes(current))
|
|
45
|
+
return next;
|
|
46
|
+
const limit = Math.min(current.length, next.length);
|
|
47
|
+
let overlap = 0;
|
|
48
|
+
for (let size = 1; size <= limit; size++)
|
|
49
|
+
if (current.endsWith(next.slice(0, size)))
|
|
50
|
+
overlap = size;
|
|
51
|
+
return current + (overlap >= 2 ? next.slice(overlap) : next);
|
|
52
|
+
}
|
|
53
|
+
function cleanDoubaoReply(reply, prompt) {
|
|
54
|
+
const value = reply.trim();
|
|
55
|
+
const input = prompt.trim();
|
|
56
|
+
if (input && value.startsWith(input) && value.length > input.length)
|
|
57
|
+
return value.slice(input.length).trim();
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
35
60
|
class DoubaoCdpClient {
|
|
36
61
|
socket;
|
|
37
62
|
sequence = 0;
|
|
@@ -84,8 +109,9 @@ class DoubaoCdpClient {
|
|
|
84
109
|
const parsed = JSON.parse(result);
|
|
85
110
|
if (!parsed.ok)
|
|
86
111
|
throw new Error(`豆包请求失败: ${parsed.reason ?? "未知错误"}`);
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
const reply = cleanDoubaoReply(parsed.reply ?? "", text);
|
|
113
|
+
if (reply)
|
|
114
|
+
return reply;
|
|
89
115
|
}
|
|
90
116
|
throw new Error("等待豆包回复超时");
|
|
91
117
|
}
|
|
@@ -115,7 +141,7 @@ function buildInjection(body) {
|
|
|
115
141
|
window.__mab_doubao_result = null;
|
|
116
142
|
window.__mab_doubao_controller?.abort();
|
|
117
143
|
window.__mab_doubao_controller = new AbortController();
|
|
118
|
-
const merge =
|
|
144
|
+
const merge = ${mergeDoubaoReply.toString()};
|
|
119
145
|
(async () => {
|
|
120
146
|
try {
|
|
121
147
|
let webId = '', tabId = '';
|
|
@@ -6,10 +6,12 @@ const node_path_1 = require("node:path");
|
|
|
6
6
|
const qrcode_1 = require("qrcode");
|
|
7
7
|
const client_1 = require("./client");
|
|
8
8
|
const manifest = { id: "feishu", version: "1.0.5", type: "channel", displayName: "飞书通道", entry: "dist/src-ts/plugins/feishu/index.js", apiVersion: "1", process: "in-process", capabilities: ["channel", "channel.bot.setup", "channel.receive", "channel.send", "health", "cli"], configSchema: "config.schema.json", managementPage: "/abweb/channels/feishu" };
|
|
9
|
+
const newSessionCommand = /^\/new(?:[ \t]+[^\r\n]+)?$/;
|
|
9
10
|
const commands = [
|
|
10
11
|
{ name: "status", summary: "查看飞书通道状态", usage: "mab feishu status" },
|
|
11
12
|
{ name: "list", summary: "列出机器人连接状态和关联 Agent", usage: "mab feishu list" },
|
|
12
13
|
{ name: "agents", summary: "列出已启动的 Agent 插件", usage: "mab feishu agents" },
|
|
14
|
+
{ name: "configure", summary: "保存机器人凭据并关联办公 Agent", usage: "mab feishu configure <app-id> [--agent <plugin-id>]", secretPrompt: "请输入飞书 App Secret" },
|
|
13
15
|
{ name: "bind", summary: "将已连接的机器人关联到 Agent", usage: "mab feishu bind <bot-id> <plugin-id>" },
|
|
14
16
|
{ name: "unbind", summary: "解除机器人关联并保留凭据", usage: "mab feishu unbind <bot-id>" },
|
|
15
17
|
];
|
|
@@ -109,13 +111,24 @@ class FeishuPlugin {
|
|
|
109
111
|
this.seen.add(key);
|
|
110
112
|
if (this.seen.size > 10000)
|
|
111
113
|
this.seen.delete(this.seen.values().next().value);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
let replyAttempted = false;
|
|
115
|
+
const channelMessage = { channel: "feishu", recipientId: appId, chatId: event.chatId, peerId: event.peerId, messageId: event.messageId, text: event.text, threadId: event.threadId, agentPlugin: this.bindings[appId] ?? this.config.agent_plugin ?? "qwenwork", reply: async (text) => { if (text.trim()) {
|
|
116
|
+
replyAttempted = true;
|
|
117
|
+
await this.clients.get(appId)?.send(event.peerId, text, event.threadId);
|
|
118
|
+
} } };
|
|
114
119
|
try {
|
|
115
120
|
await this.ingress.publish(channelMessage);
|
|
116
121
|
}
|
|
117
122
|
catch (error) {
|
|
118
123
|
this.context?.logger.warn("飞书消息处理失败", { error: error instanceof Error ? error.message : String(error) });
|
|
124
|
+
if (!replyAttempted && newSessionCommand.test(event.text.trim())) {
|
|
125
|
+
try {
|
|
126
|
+
await channelMessage.reply("新建会话失败,请等待当前任务结束后重试;如仍失败请检查后台日志");
|
|
127
|
+
}
|
|
128
|
+
catch (replyError) {
|
|
129
|
+
this.context?.logger.warn("飞书错误回复失败", { error: replyError instanceof Error ? replyError.message : String(replyError) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
134
|
validate() { for (const key of ["app_id", "app_secret", "agent_plugin", "allow_from", "allow_chats", "ws_url", "api_base"])
|
|
@@ -137,8 +150,10 @@ class FeishuPlugin {
|
|
|
137
150
|
}
|
|
138
151
|
if (body.protocolVersion !== 1 || !Array.isArray(body.argv) || !body.argv.every((arg) => typeof arg === "string"))
|
|
139
152
|
return this.json(response, 400, { ok: false, message: "命令协议版本或 argv 无效" });
|
|
140
|
-
const [command, appId,
|
|
141
|
-
const
|
|
153
|
+
const [command, appId, pluginArg] = body.argv;
|
|
154
|
+
const pluginId = command === "configure" && body.argv[2] === "--agent" ? body.argv[3] : pluginArg;
|
|
155
|
+
const secret = typeof body.secret === "string" ? body.secret.trim() : "";
|
|
156
|
+
const expectedLength = command === "bind" ? 3 : command === "unbind" ? 2 : command === "configure" ? (body.argv.length === 4 ? 4 : 2) : 1;
|
|
142
157
|
if (!commands.some((item) => item.name === command) || body.argv.length !== expectedLength || body.argv.some((arg) => !arg.trim()))
|
|
143
158
|
return this.json(response, 400, { ok: false, message: "命令或参数无效,请查看 mab feishu help" });
|
|
144
159
|
if (command === "status") {
|
|
@@ -149,34 +164,44 @@ class FeishuPlugin {
|
|
|
149
164
|
const bots = this.bots.map((bot) => ({ app_id: bot.app_id, status: this.botStates.get(bot.app_id)?.status || "degraded", pluginId: this.bindings[bot.app_id] || "" }));
|
|
150
165
|
return this.json(response, 200, { ok: true, message: bots.map((bot) => `${bot.app_id}:${bot.status},Agent:${bot.pluginId || "未关联"}`).join("\n") || "尚未配置飞书机器人", data: bots });
|
|
151
166
|
}
|
|
152
|
-
if (command
|
|
167
|
+
if (command === "configure" && (!appId || !secret || body.argv.length === 4 && (body.argv[2] !== "--agent" || !pluginId)))
|
|
168
|
+
return this.json(response, 400, { ok: false, message: "用法:mab feishu configure <app-id> [--agent <plugin-id>],Secret 必须输入" });
|
|
169
|
+
if (command !== "configure" && command !== "agents" && !this.bots.some((bot) => bot.app_id === appId))
|
|
153
170
|
return this.json(response, 400, { ok: false, message: "飞书机器人标识不存在" });
|
|
154
171
|
if (command === "bind" && this.botStates.get(appId)?.status !== "ready")
|
|
155
172
|
return this.json(response, 400, { ok: false, message: "飞书机器人当前未连接" });
|
|
156
|
-
if (command === "agents" || command === "bind") {
|
|
173
|
+
if (command === "agents" || command === "bind" || command === "configure" && pluginId) {
|
|
157
174
|
const runtime = this.context?.runtime;
|
|
158
175
|
const controlUrl = runtime && "controlUrl" in runtime ? runtime.controlUrl : undefined;
|
|
159
176
|
if (typeof controlUrl !== "string" || !controlUrl)
|
|
160
177
|
return this.json(response, 503, { ok: false, message: "运行时未提供控制 API 地址,请升级核心并重启后台" });
|
|
161
178
|
let agents;
|
|
162
179
|
try {
|
|
163
|
-
const result = await fetch(new URL("/api/plugins", controlUrl), { signal: AbortSignal.timeout(5000) });
|
|
180
|
+
const result = await fetch(new URL(command === "configure" ? "/api/agents/availability" : "/api/plugins", controlUrl), { signal: AbortSignal.timeout(5000) });
|
|
164
181
|
if (!result.ok)
|
|
165
182
|
throw new Error("控制 API 请求失败");
|
|
166
183
|
const data = await result.json();
|
|
167
|
-
|
|
184
|
+
const entries = command === "configure" ? data.agents : data.plugins;
|
|
185
|
+
if (!Array.isArray(entries))
|
|
168
186
|
throw new Error("控制 API 响应无效");
|
|
169
|
-
agents =
|
|
187
|
+
agents = entries.filter((item) => item && typeof item.id === "string" && (command === "configure" ? item.available === true : item.type === "agent" && item.status === "ready")).map((item) => ({ id: item.id }));
|
|
170
188
|
}
|
|
171
189
|
catch {
|
|
172
190
|
return this.json(response, 502, { ok: false, message: "无法读取当前 Agent 插件,请检查后台控制 API" });
|
|
173
191
|
}
|
|
174
192
|
if (command === "agents")
|
|
175
193
|
return this.json(response, 200, { ok: true, message: agents.map((agent) => agent.id).join("\n") || "没有已启动的 Agent 插件", data: agents });
|
|
176
|
-
if (!agents.some((agent) => agent.id === pluginId))
|
|
194
|
+
if (pluginId && !agents.some((agent) => agent.id === pluginId))
|
|
177
195
|
return this.json(response, 400, { ok: false, message: "目标 Agent 插件未启动或类型无效" });
|
|
178
196
|
}
|
|
179
197
|
try {
|
|
198
|
+
if (command === "configure") {
|
|
199
|
+
this.saveBot(appId, secret);
|
|
200
|
+
await this.persist();
|
|
201
|
+
if (pluginId)
|
|
202
|
+
await this.bindBot(appId, pluginId);
|
|
203
|
+
return this.json(response, 200, { ok: true, message: "飞书凭据已保存,请重载飞书插件以连接机器人", data: { app_id: appId, ...(pluginId ? { pluginId } : {}) } });
|
|
204
|
+
}
|
|
180
205
|
await this.bindBot(appId, command === "bind" ? pluginId : "");
|
|
181
206
|
this.json(response, 200, { ok: true, message: command === "bind" ? "飞书机器人已关联 Agent" : "飞书机器人已解除 Agent 关联", data: { app_id: appId, ...(command === "bind" ? { pluginId } : {}) } });
|
|
182
207
|
}
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
if (loading) return;
|
|
30
30
|
loading = true;
|
|
31
31
|
try {
|
|
32
|
-
const [state,
|
|
33
|
-
const agents =
|
|
34
|
-
const
|
|
32
|
+
const [state, availability] = await Promise.all([request("/api/plugins/feishu/setup/status"), request("/api/agents/availability")]);
|
|
33
|
+
const agents = availability.agents || [];
|
|
34
|
+
const agentOptions = () => agents.map((agent) => { const option = new Option(`${agent.displayName || agent.id}(${agent.available ? "可用" : agent.reason})`, agent.id); option.disabled = !agent.available; return option; });
|
|
35
35
|
bots = state.bots || [];
|
|
36
36
|
botTable.replaceChildren();
|
|
37
37
|
for (const bot of [...bots].sort((a, b) => Number(Boolean(b.pluginId)) - Number(Boolean(a.pluginId)) || Number(b.status === "ready") - Number(a.status === "ready") || (Date.parse(b.binding_time) || 0) - (Date.parse(a.binding_time) || 0))) {
|
|
@@ -39,18 +39,20 @@
|
|
|
39
39
|
for (const text of [bot.app_id, bot.status === "ready" ? "已连接" : bot.message || "未连接", bot.pluginId || "未关联", bot.binding_time ? new Date(bot.binding_time).toLocaleString() : "—"]) { const cell = document.createElement("td"); cell.textContent = text; row.append(cell); }
|
|
40
40
|
const controls = document.createElement("td");
|
|
41
41
|
const select = document.createElement("select"); select.setAttribute("aria-label", `${bot.app_id} 关联 Agent`);
|
|
42
|
-
select.append(new Option("选择 Agent", ""), ...
|
|
42
|
+
select.append(new Option("选择 Agent", ""), ...agentOptions()); select.value = bot.pluginId || "";
|
|
43
43
|
function button(text, action) { const button = document.createElement("button"); button.type = "button"; button.textContent = text; button.onclick = async () => { button.disabled = true; try { await action(); } catch (error) { show(String(error), true); } finally { button.disabled = false; } }; return button; }
|
|
44
|
-
async function bind(pluginId) { await request("/api/plugins/feishu/bind", "POST", { app_id: bot.app_id, pluginId }); await load(false); show(`${bot.app_id} ${pluginId ? "已关联 " + pluginId : "已解除关联"}`); }
|
|
45
|
-
controls.append(select, button("关联", () => bind(select.value)), button("解除", () => bind("")), button("编辑凭据", async () => { credentials.elements.app_id.value = bot.app_id; savedAppId = bot.app_id; secretMasked = true; credentials.elements.app_secret.value = "********"; credentials.elements.app_secret.required = false; }));
|
|
44
|
+
async function bind(pluginId) { if (pluginId && !agents.find((agent) => agent.id === pluginId)?.available) throw new Error("目标办公 Agent 不可用"); await request("/api/plugins/feishu/bind", "POST", { app_id: bot.app_id, pluginId }); await load(false); show(`${bot.app_id} ${pluginId ? "已关联 " + pluginId : "已解除关联"}`); }
|
|
45
|
+
controls.append(select, button("关联", () => bind(select.value)), button("解除", () => bind("")), button("编辑凭据", async () => { credentials.elements.app_id.value = bot.app_id; credentials.elements.agent_plugin.value = bot.pluginId || ""; savedAppId = bot.app_id; secretMasked = true; credentials.elements.app_secret.value = "********"; credentials.elements.app_secret.required = false; }));
|
|
46
46
|
row.append(controls); botTable.append(row);
|
|
47
47
|
}
|
|
48
48
|
if (refreshForm) {
|
|
49
|
-
|
|
49
|
+
credentials.elements.agent_plugin.replaceChildren(new Option("保留原有关联", ""), ...agentOptions());
|
|
50
|
+
settings.elements.agent_plugin.replaceChildren(new Option("默认 Agent", ""), ...agentOptions());
|
|
50
51
|
settings.elements.agent_plugin.value = state.agent_plugin || "";
|
|
51
52
|
settings.elements.allow_from.value = state.allow_from || "";
|
|
52
53
|
settings.elements.allow_chats.value = state.allow_chats || "";
|
|
53
54
|
credentials.elements.app_id.value = state.app_id || "";
|
|
55
|
+
credentials.elements.agent_plugin.value = state.bindings?.[credentials.elements.app_id.value] || "";
|
|
54
56
|
savedAppId = state.configured ? state.app_id : "";
|
|
55
57
|
secretMasked = state.configured;
|
|
56
58
|
credentials.elements.app_secret.value = secretMasked ? "********" : "";
|
|
@@ -120,14 +122,24 @@
|
|
|
120
122
|
credentials.elements.app_id.oninput = () => {
|
|
121
123
|
|
|
122
124
|
const changed = credentials.elements.app_id.value.trim() !== savedAppId;
|
|
125
|
+
if (changed) credentials.elements.agent_plugin.value = "";
|
|
123
126
|
if (changed && secretMasked) { credentials.elements.app_secret.value = ""; secretMasked = false; }
|
|
124
127
|
credentials.elements.app_secret.required = changed;
|
|
125
128
|
};
|
|
126
129
|
credentials.elements.app_secret.oninput = () => { secretMasked = false; };
|
|
127
130
|
credentials.elements.app_secret.addEventListener("invalid", () => show("首次配置或更换机器人时,App ID 和 App Secret 都需要填写", true));
|
|
131
|
+
function selectedAgentUnavailable() { return credentials.elements.agent_plugin.selectedOptions?.[0]?.disabled || false; }
|
|
128
132
|
credentials.onsubmit = async (event) => {
|
|
129
133
|
event.preventDefault(); const button = credentials.querySelector("button"); button.disabled = true; show("正在保存凭据并连接飞书");
|
|
130
|
-
try {
|
|
134
|
+
try {
|
|
135
|
+
const agentPlugin = credentials.elements.agent_plugin.value;
|
|
136
|
+
if (agentPlugin && selectedAgentUnavailable()) throw new Error("目标办公 Agent 不可用");
|
|
137
|
+
await request("/api/plugins/feishu/bot", "POST", { app_id: credentials.elements.app_id.value.trim(), app_secret: secretMasked ? "" : credentials.elements.app_secret.value });
|
|
138
|
+
savedAppId = credentials.elements.app_id.value.trim();
|
|
139
|
+
secretMasked = true; credentials.elements.app_secret.value = "********";
|
|
140
|
+
if (agentPlugin) await request("/api/plugins/feishu/bind", "POST", { app_id: savedAppId, pluginId: agentPlugin });
|
|
141
|
+
await reconnect(); show("凭据已保存,机器人已连接");
|
|
142
|
+
}
|
|
131
143
|
catch (error) { show(String(error), true); }
|
|
132
144
|
finally { button.disabled = false; }
|
|
133
145
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<dl><div><dt>连接状态</dt><dd id="connection">未配置</dd></div><div><dt>App ID</dt><dd id="app-id">未配置</dd></div><div><dt>关联 Agent</dt><dd id="bound-agent">未关联</dd></div></dl>
|
|
9
9
|
<section><h2>机器人关联</h2><div class="table-wrap"><table><thead><tr><th>App ID</th><th>连接状态</th><th>关联 Agent</th><th>最近关联时间</th><th>操作</th></tr></thead><tbody id="bots"></tbody></table></div></section>
|
|
10
10
|
<section><h2>创建设备授权</h2><div class="actions"><button id="begin" type="button">创建飞书机器人</button><a id="authorization-link" target="_blank" rel="noopener noreferrer" hidden>打开飞书创建链接</a></div><div id="authorization-details" hidden><img id="authorization-qr" width="256" height="256" alt="飞书机器人创建授权二维码"><p id="authorization-url"></p></div><p id="authorization-state" role="status"></p></section>
|
|
11
|
-
<form id="credentials"><h2>手动配置凭据</h2><div class="fields"><label>App ID<input name="app_id" required autocomplete="off"></label><label>App Secret<input name="app_secret" type="password" required autocomplete="new-password"></label></div><button type="submit">保存并连接</button></form>
|
|
11
|
+
<form id="credentials"><h2>手动配置凭据</h2><div class="fields"><label>App ID<input name="app_id" required autocomplete="off"></label><label>App Secret<input name="app_secret" type="password" required autocomplete="new-password"></label><label>关联办公 Agent<select name="agent_plugin"></select></label></div><button type="submit">保存并连接</button></form>
|
|
12
12
|
|
|
13
13
|
<form id="settings"><h2>通道配置</h2><div class="fields"><label>默认 Agent<select name="agent_plugin"></select></label><label>允许的 open_id<input name="allow_from"></label><label>允许的 chat_id<input name="allow_chats"></label></div><button type="submit">保存配置</button></form>
|
|
14
14
|
</main>
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.QwenCdpClient = exports.inputSelector = exports.assistantMessageSelector = void 0;
|
|
4
4
|
exports.freshAssistantMessages = freshAssistantMessages;
|
|
5
|
-
|
|
5
|
+
exports.verifyQwenHeartbeatReply = verifyQwenHeartbeatReply;
|
|
6
|
+
exports.isQwenHeartbeatReplyComplete = isQwenHeartbeatReplyComplete;
|
|
7
|
+
exports.isQwenApiUnavailableReply = isQwenApiUnavailableReply;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
9
|
exports.assistantMessageSelector = '[data-message-id][data-part-type="text"]:not([data-conversation-user-text="true"])';
|
|
7
10
|
exports.inputSelector = '.chat-input-editor-text[contenteditable="true"]';
|
|
8
11
|
function freshAssistantMessages(beforeIds, messages) {
|
|
9
12
|
return messages.filter((message) => Boolean(message.id) && !beforeIds.has(message.id) && Boolean(message.text?.trim()));
|
|
10
13
|
}
|
|
14
|
+
function verifyQwenHeartbeatReply(expected, actual) {
|
|
15
|
+
if (!isQwenHeartbeatReplyComplete(actual, expected))
|
|
16
|
+
throw new Error(`真实对话心跳未通过:期望 ${expected},实际 ${actual.slice(0, 120)}`);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
function isQwenHeartbeatReplyComplete(reply, expected) {
|
|
20
|
+
return reply.trim() === expected;
|
|
21
|
+
}
|
|
22
|
+
function isQwenApiUnavailableReply(reply) {
|
|
23
|
+
return /^(?:API|接口)\s*(?:暂时)?不可用[。.!!]?$/i.test(reply.trim());
|
|
24
|
+
}
|
|
11
25
|
function wait(ms, signal) {
|
|
12
26
|
if (signal?.aborted)
|
|
13
27
|
return Promise.reject(new Error("请求已取消"));
|
|
@@ -19,7 +33,7 @@ function wait(ms, signal) {
|
|
|
19
33
|
}, { once: true });
|
|
20
34
|
});
|
|
21
35
|
}
|
|
22
|
-
/** CDP/tRPC
|
|
36
|
+
/** 千问桌面客户端的 CDP/tRPC 通信。 */
|
|
23
37
|
class QwenCdpClient {
|
|
24
38
|
socket;
|
|
25
39
|
sequence = 0;
|
|
@@ -35,14 +49,40 @@ class QwenCdpClient {
|
|
|
35
49
|
fetchImpl: options.fetchImpl,
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
|
-
connected() { return
|
|
52
|
+
connected() { return this.socket?.readyState === WebSocket.OPEN; }
|
|
53
|
+
async healthCheck() {
|
|
54
|
+
if (!this.socket)
|
|
55
|
+
throw new Error("千问 CDP 尚未连接");
|
|
56
|
+
const result = await this.evaluate(`(() => Boolean(window.electronTRPC && document.querySelector(${JSON.stringify(exports.inputSelector)})))()`);
|
|
57
|
+
if (result !== true)
|
|
58
|
+
throw new Error("千问主页面或输入框无响应");
|
|
59
|
+
}
|
|
60
|
+
async chatHeartbeat() {
|
|
61
|
+
await this.healthCheck();
|
|
62
|
+
const token = `MAB_QWEN_${(0, node_crypto_1.randomUUID)().slice(0, 8).toUpperCase()}`;
|
|
63
|
+
const reply = await this.direct(`这是后台健康检测。请只回复 ${token},不要添加任何其它文字。`, 45_000, undefined, token);
|
|
64
|
+
verifyQwenHeartbeatReply(token, reply);
|
|
65
|
+
}
|
|
66
|
+
async createSession(_name) {
|
|
67
|
+
const snapshot = `(() => ({ url: location.href, messages: document.querySelectorAll('[data-message-id]').length, input: document.querySelector(${JSON.stringify(exports.inputSelector)})?.innerText ?? null, blank: Boolean(document.querySelector('.new-task-model-selector')) }))()`;
|
|
68
|
+
const before = await this.evaluate(snapshot);
|
|
69
|
+
if (before.input?.trim())
|
|
70
|
+
throw new Error("千问输入框含有未发送内容,不能新建会话");
|
|
71
|
+
if (before.input !== null && before.messages === 0 && before.blank)
|
|
72
|
+
return;
|
|
73
|
+
const clicked = await this.evaluate(`(() => { const button = document.querySelector('button[aria-label="新任务"]'); if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') return false; button.click(); return true; })()`);
|
|
74
|
+
if (clicked !== true)
|
|
75
|
+
throw new Error("千问新任务入口未找到或不可用");
|
|
76
|
+
await this.waitFor(`(() => { const state = ${snapshot}; return state.input !== null && state.input.trim() === '' && state.messages === 0 && state.url !== ${JSON.stringify(before.url)}; })()`, "千问新会话状态校验失败");
|
|
77
|
+
}
|
|
39
78
|
async connect() {
|
|
40
|
-
if (this.
|
|
79
|
+
if (this.connected())
|
|
41
80
|
return;
|
|
81
|
+
this.close();
|
|
42
82
|
const fetchImpl = this.options.fetchImpl ?? fetch;
|
|
43
83
|
let response;
|
|
44
84
|
try {
|
|
45
|
-
response = await fetchImpl(`http://127.0.0.1:${this.options.port}/json
|
|
85
|
+
response = await fetchImpl(`http://127.0.0.1:${this.options.port}/json`, { signal: AbortSignal.timeout(this.options.timeoutMs) });
|
|
46
86
|
}
|
|
47
87
|
catch (error) {
|
|
48
88
|
const code = error?.cause?.code ?? String(error);
|
|
@@ -55,24 +95,34 @@ class QwenCdpClient {
|
|
|
55
95
|
?? targets.find((target) => target.type === "page" && (target.url ?? "").includes("index.html"));
|
|
56
96
|
if (!page?.webSocketDebuggerUrl)
|
|
57
97
|
throw new Error(`未找到千问主窗口 target(客户端是否以 --remote-debugging-port=${this.options.port} 启动?)`);
|
|
58
|
-
|
|
59
|
-
this.socket
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
99
|
+
this.socket = socket;
|
|
100
|
+
socket.onmessage = (event) => this.onMessage(String(event.data));
|
|
101
|
+
socket.onclose = () => { if (this.socket === socket)
|
|
102
|
+
this.close(); };
|
|
103
|
+
try {
|
|
104
|
+
await new Promise((resolve, reject) => {
|
|
105
|
+
const timer = setTimeout(() => { reject(new Error("CDP WebSocket 连接超时")); }, this.options.timeoutMs);
|
|
106
|
+
socket.onopen = () => { clearTimeout(timer); resolve(); };
|
|
107
|
+
socket.onerror = () => { clearTimeout(timer); reject(new Error("CDP WebSocket 连接失败")); };
|
|
108
|
+
});
|
|
109
|
+
await this.evaluate(`(() => {
|
|
110
|
+
if (!window.__ts_bridge_resps) {
|
|
111
|
+
window.__ts_bridge_resps = [];
|
|
112
|
+
window.electronTRPC.onMessage(message => window.__ts_bridge_resps.push(message));
|
|
113
|
+
}
|
|
114
|
+
return 'ok';
|
|
115
|
+
})()`);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
this.close();
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
72
121
|
}
|
|
73
122
|
close() {
|
|
74
|
-
this.socket
|
|
123
|
+
const socket = this.socket;
|
|
75
124
|
this.socket = undefined;
|
|
125
|
+
socket?.close();
|
|
76
126
|
for (const pending of this.pending.values())
|
|
77
127
|
pending.reject(new Error("CDP 已关闭"));
|
|
78
128
|
this.pending.clear();
|
|
@@ -85,7 +135,10 @@ class QwenCdpClient {
|
|
|
85
135
|
cwd: this.options.workspace,
|
|
86
136
|
});
|
|
87
137
|
}
|
|
88
|
-
async direct(text, timeoutMs = 240_000, signal) {
|
|
138
|
+
async direct(text, timeoutMs = 240_000, signal, expectedReply) {
|
|
139
|
+
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"]')); })()`);
|
|
140
|
+
if (!ready)
|
|
141
|
+
throw new Error("千问输入框含有未发送内容或正在生成回复,未发送新消息");
|
|
89
142
|
const snapshot = await this.evaluate(`JSON.stringify([...document.querySelectorAll(${JSON.stringify(exports.assistantMessageSelector)})].map(element => ({ id: element.getAttribute('data-message-id'), text: element.innerText })))`);
|
|
90
143
|
const before = JSON.parse(snapshot || "[]");
|
|
91
144
|
const beforeIds = new Set(before.map((message) => message.id).filter((id) => Boolean(id)));
|
|
@@ -98,12 +151,10 @@ class QwenCdpClient {
|
|
|
98
151
|
if (!focused)
|
|
99
152
|
throw new Error("千问输入框未找到");
|
|
100
153
|
await this.cdp("Input.insertText", { text });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
}
|
|
154
|
+
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; })()`);
|
|
155
|
+
if (sent !== true)
|
|
156
|
+
throw new Error("千问发送按钮未就绪或输入校验失败,未发送消息");
|
|
157
|
+
await this.waitFor(`(() => { const input = document.querySelector(${JSON.stringify(exports.inputSelector)}); return Boolean(input && !input.innerText.trim()); })()`, "千问未确认消息已提交");
|
|
107
158
|
let previousReply = "";
|
|
108
159
|
let stablePolls = 0;
|
|
109
160
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -114,7 +165,9 @@ class QwenCdpClient {
|
|
|
114
165
|
const reply = fresh.at(-1)?.text.trim() ?? "";
|
|
115
166
|
stablePolls = reply && reply === previousReply ? stablePolls + 1 : 0;
|
|
116
167
|
previousReply = reply;
|
|
117
|
-
if (reply && stablePolls >= 3)
|
|
168
|
+
if (reply && stablePolls >= 3 && isQwenApiUnavailableReply(reply))
|
|
169
|
+
throw new Error("千问生成接口不可用");
|
|
170
|
+
if (expectedReply ? isQwenHeartbeatReplyComplete(reply, expectedReply) && stablePolls >= 1 : reply && stablePolls >= 3)
|
|
118
171
|
return reply;
|
|
119
172
|
}
|
|
120
173
|
throw new Error("千问办公 UI 超时未返回回复");
|
|
@@ -139,9 +192,21 @@ class QwenCdpClient {
|
|
|
139
192
|
if (!this.socket)
|
|
140
193
|
throw new Error("CDP 尚未连接");
|
|
141
194
|
const id = ++this.sequence;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP ${method} 超时`)); }, timeoutMs);
|
|
197
|
+
this.pending.set(id, {
|
|
198
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
199
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
this.socket.send(JSON.stringify({ id, method, params }));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
this.pending.delete(id);
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
reject(error);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
145
210
|
}
|
|
146
211
|
async evaluate(expression) {
|
|
147
212
|
const response = await this.cdp("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
@@ -168,5 +233,14 @@ class QwenCdpClient {
|
|
|
168
233
|
}
|
|
169
234
|
throw new Error(`tRPC ${path} 超时`);
|
|
170
235
|
}
|
|
236
|
+
async waitFor(expression, message) {
|
|
237
|
+
const deadline = Date.now() + this.options.timeoutMs;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
if (await this.evaluate(expression))
|
|
240
|
+
return;
|
|
241
|
+
await wait(200);
|
|
242
|
+
}
|
|
243
|
+
throw new Error(message);
|
|
244
|
+
}
|
|
171
245
|
}
|
|
172
246
|
exports.QwenCdpClient = QwenCdpClient;
|