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.
Files changed (73) hide show
  1. package/README.md +17 -1
  2. package/dist/installer/mu-agent-bridge.msi +0 -0
  3. package/dist/src-ts/agent-availability.js +14 -0
  4. package/dist/src-ts/channel-sessions.js +28 -5
  5. package/dist/src-ts/cli-commands.js +6 -2
  6. package/dist/src-ts/control-server.js +13 -2
  7. package/dist/src-ts/daemon-process.js +3 -2
  8. package/dist/src-ts/dsh-agent.js +4 -2
  9. package/dist/src-ts/plugin-manager.js +53 -3
  10. package/dist/src-ts/plugins/aun/index.js +52 -7
  11. package/dist/src-ts/plugins/aun/public/index.html +1 -1
  12. package/dist/src-ts/plugins/chatgpt/cdp-client.js +31 -2
  13. package/dist/src-ts/plugins/chatgpt/index.js +37 -1
  14. package/dist/src-ts/plugins/chatgpt/session-store.js +6 -2
  15. package/dist/src-ts/plugins/desktop-launch.js +13 -2
  16. package/dist/src-ts/plugins/doubao/cdp-client.js +29 -3
  17. package/dist/src-ts/plugins/dsh/index.js +1 -0
  18. package/dist/src-ts/plugins/feishu/index.js +35 -10
  19. package/dist/src-ts/plugins/feishu/public/app.js +20 -8
  20. package/dist/src-ts/plugins/feishu/public/index.html +1 -1
  21. package/dist/src-ts/plugins/qwenwork/cdp-client.js +105 -31
  22. package/dist/src-ts/plugins/qwenwork/index.js +201 -7
  23. package/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
  24. package/dist/src-ts/plugins/workbuddy/cdp-client.js +10 -4
  25. package/dist/src-ts/plugins/workbuddy/index.js +139 -7
  26. package/dist/src-ts/secret-input.js +47 -0
  27. package/dist/tests-ts/agent-availability.test.js +27 -0
  28. package/dist/tests-ts/aun-default-owner-page.test.js +81 -0
  29. package/dist/tests-ts/aun-plugin.test.js +152 -8
  30. package/dist/tests-ts/channel-sessions.test.js +189 -0
  31. package/dist/tests-ts/chatgpt-plugin.test.js +104 -0
  32. package/dist/tests-ts/cli-commands.test.js +58 -0
  33. package/dist/tests-ts/control-server.test.js +51 -0
  34. package/dist/tests-ts/daemon-process.test.js +1 -0
  35. package/dist/tests-ts/desktop-launch.test.js +40 -0
  36. package/dist/tests-ts/doubao-plugin.test.js +10 -0
  37. package/dist/tests-ts/dsh-agent.test.js +25 -0
  38. package/dist/tests-ts/feishu-page.test.js +10 -3
  39. package/dist/tests-ts/feishu-plugin.test.js +62 -1
  40. package/dist/tests-ts/installer-assets.test.js +84 -0
  41. package/dist/tests-ts/plugin-manager.test.js +170 -2
  42. package/dist/tests-ts/qwenwork-plugin.test.js +443 -0
  43. package/dist/tests-ts/qwenwork-sse.test.js +46 -0
  44. package/dist/tests-ts/workbuddy-plugin.test.js +278 -3
  45. package/package.json +2 -1
  46. package/plugins/aun/config.schema.json +1 -0
  47. package/plugins/aun/dist/src-ts/plugins/aun/index.js +52 -7
  48. package/plugins/aun/dist/src-ts/plugins/aun/public/index.html +1 -1
  49. package/plugins/aun/package.json +1 -1
  50. package/plugins/aun/plugin.json +1 -1
  51. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +31 -2
  52. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +37 -1
  53. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/session-store.js +6 -2
  54. package/plugins/chatgpt/package.json +1 -1
  55. package/plugins/chatgpt/plugin.json +1 -1
  56. package/plugins/doubao/dist/src-ts/plugins/desktop-launch.js +13 -2
  57. package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +29 -3
  58. package/plugins/dsh/dist/src-ts/dsh-agent.js +4 -2
  59. package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +1 -0
  60. package/plugins/dsh/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
  61. package/plugins/feishu/dist/src-ts/plugins/feishu/index.js +35 -10
  62. package/plugins/feishu/dist/src-ts/plugins/feishu/public/app.js +20 -8
  63. package/plugins/feishu/dist/src-ts/plugins/feishu/public/index.html +1 -1
  64. package/plugins/qwenwork/config.schema.json +3 -0
  65. package/plugins/qwenwork/dist/src-ts/plugins/desktop-launch.js +13 -2
  66. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +105 -31
  67. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +201 -7
  68. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/openai-adapter.js +12 -2
  69. package/plugins/qwenwork/package.json +1 -1
  70. package/plugins/qwenwork/plugin.json +2 -2
  71. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +10 -4
  72. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +139 -7
  73. 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
- return (0, node_fs_1.existsSync)((0, node_path_1.resolve)(configured)) ? (0, node_path_1.resolve)(configured) : undefined;
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
- if (parsed.reply?.trim())
88
- return parsed.reply.trim();
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 = (a, b) => { if (!b || a.endsWith(b)) return a; const n = Math.min(a.length, b.length); let k = 0; for (let i = 1; i <= n; i++) if (a.endsWith(b.slice(0, i))) k = i; return a + (k >= 4 ? b.slice(k) : b); };
144
+ const merge = ${mergeDoubaoReply.toString()};
119
145
  (async () => {
120
146
  try {
121
147
  let webId = '', tabId = '';
@@ -16,6 +16,7 @@ exports.dshManifest = {
16
16
  };
17
17
  class DshPlugin {
18
18
  manifest = exports.dshManifest;
19
+ sessionOperations = ["new"];
19
20
  agent;
20
21
  status = "failed";
21
22
  message = "插件尚未启动";
@@ -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
- 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())
113
- await this.clients.get(appId)?.send(event.peerId, text, event.threadId); } };
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, pluginId] = body.argv;
141
- const expectedLength = command === "bind" ? 3 : command === "unbind" ? 2 : 1;
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 !== "agents" && !this.bots.some((bot) => bot.app_id === appId))
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
- if (!Array.isArray(data.plugins))
184
+ const entries = command === "configure" ? data.agents : data.plugins;
185
+ if (!Array.isArray(entries))
168
186
  throw new Error("控制 API 响应无效");
169
- agents = data.plugins.filter((item) => item && item.type === "agent" && item.status === "ready" && typeof item.id === "string").map((item) => ({ id: item.id }));
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, plugins] = await Promise.all([request("/api/plugins/feishu/setup/status"), request("/api/plugins")]);
33
- const agents = (plugins.plugins || []).filter((plugin) => plugin.type === "agent" && plugin.status === "ready");
34
- const options = agents.map((agent) => new Option(agent.displayName || agent.id, agent.id));
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", ""), ...agents.map((agent) => new Option(agent.displayName || agent.id, agent.id))); select.value = bot.pluginId || "";
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
- settings.elements.agent_plugin.replaceChildren(new Option("默认 Agent", ""), ...options);
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 { await request("/api/plugins/feishu/bot", "POST", { app_id: credentials.elements.app_id.value.trim(), app_secret: secretMasked ? "" : credentials.elements.app_secret.value }); savedAppId = credentials.elements.app_id.value.trim(); secretMasked = true; credentials.elements.app_secret.value = "********"; await reconnect(); show("凭据已保存,机器人已连接"); }
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
- function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
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 transport for the QwenWorkCN desktop client. */
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 Boolean(this.socket); }
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.socket)
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
- this.socket = new WebSocket(page.webSocketDebuggerUrl);
59
- this.socket.onmessage = (event) => this.onMessage(String(event.data));
60
- await new Promise((resolve, reject) => {
61
- const socket = this.socket;
62
- socket.onopen = () => resolve();
63
- socket.onerror = () => reject(new Error("CDP WebSocket 连接失败"));
64
- });
65
- await this.evaluate(`(() => {
66
- if (!window.__ts_bridge_resps) {
67
- window.__ts_bridge_resps = [];
68
- window.electronTRPC.onMessage(message => window.__ts_bridge_resps.push(message));
69
- }
70
- return 'ok';
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?.close();
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
- for (const type of ["keyDown", "keyUp"]) {
102
- await this.cdp("Input.dispatchKeyEvent", {
103
- type, key: "Enter", code: "Enter", text: type === "keyDown" ? "\r" : "", unmodifiedText: type === "keyDown" ? "\r" : "",
104
- windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
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
- const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
143
- this.socket.send(JSON.stringify({ id, method, params }));
144
- return Promise.race([result, sleep(timeoutMs).then(() => { throw new Error(`CDP ${method} 超时`); })]);
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;