made-bridge 0.0.0-stage → 0.2.1

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/builtin/plugins/README.md +22 -0
  4. package/builtin/themes/README.md +64 -0
  5. package/builtin/themes/sunset/theme.json +19 -0
  6. package/config.example.json +20 -0
  7. package/demo-project/README.md +5 -0
  8. package/demo-project/index.mjs +14 -0
  9. package/package.json +48 -4
  10. package/public/app.js +1028 -0
  11. package/public/assistants.html +67 -0
  12. package/public/bean-icon.png +0 -0
  13. package/public/i18n-messages.js +967 -0
  14. package/public/i18n-static.js +615 -0
  15. package/public/i18n.js +50 -0
  16. package/public/index.html +149 -0
  17. package/public/made-icon.png +0 -0
  18. package/public/plugins.html +64 -0
  19. package/public/plugins.js +335 -0
  20. package/public/provider-icon.js +77 -0
  21. package/public/styles.css +445 -0
  22. package/public/tasks.html +85 -0
  23. package/src/agent-icons.mjs +138 -0
  24. package/src/agents/codex.mjs +94 -0
  25. package/src/agents/cursor.mjs +31 -0
  26. package/src/agents/events.mjs +92 -0
  27. package/src/agents/index.mjs +7 -0
  28. package/src/agents/qoder.mjs +34 -0
  29. package/src/agents/shared.mjs +191 -0
  30. package/src/agents/workbuddy.mjs +121 -0
  31. package/src/board-link.mjs +266 -0
  32. package/src/config.mjs +196 -0
  33. package/src/doctor.mjs +146 -0
  34. package/src/i18n.mjs +257 -0
  35. package/src/installed.mjs +291 -0
  36. package/src/jobs.mjs +369 -0
  37. package/src/language-settings.mjs +38 -0
  38. package/src/model-settings.mjs +63 -0
  39. package/src/openai-key.mjs +47 -0
  40. package/src/oray.mjs +243 -0
  41. package/src/pairing.mjs +203 -0
  42. package/src/plugin-package.mjs +132 -0
  43. package/src/plugin-runtime.mjs +169 -0
  44. package/src/plugins/contracts.d.ts +184 -0
  45. package/src/plugins/enhance.mjs +156 -0
  46. package/src/plugins/registry.mjs +146 -0
  47. package/src/providers.mjs +75 -0
  48. package/src/server.mjs +950 -0
  49. package/src/sessions.mjs +134 -0
  50. package/src/speech/index.mjs +10 -0
  51. package/src/speech/openai.mjs +48 -0
  52. package/src/speech/whisper.mjs +59 -0
  53. package/src/speech-settings.mjs +209 -0
  54. package/src/transcribe.mjs +85 -0
  55. package/src/tunnel.mjs +531 -0
  56. package/src/usb-receiver.mjs +664 -0
  57. package/src/voice-settings.mjs +69 -0
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs';
2
+ import { translateKnownMessage } from '../i18n.mjs';
3
+ import path from 'node:path';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { findCommand, isWindows, windowsCommandLine } from '../plugin-runtime.mjs';
7
+
8
+ const authenticationCache = new Map();
9
+
10
+ /**
11
+ * 每轮任务都要带上的权限边界。CLI 助手拥有桥接器进程的权限,这条约束是设备侧
12
+ * 唯一的护栏,不能只在某几个适配器里各写一遍,所以统一在 runCli 里拼。
13
+ */
14
+ const GUARD_INSTRUCTION = '请只修改当前仓库中的文件;不要提交、推送、发布或删除仓库。完成后说明改动与已运行的验证。';
15
+
16
+ export function probeCli(command, loginArgs) {
17
+ // 命令查找走共享的跨平台实现:Windows 上要按 PATHEXT 补 .cmd/.exe,
18
+ // 且 X_OK 在那里没有意义,自己写一遍最容易漏。
19
+ const found = findCommand(command);
20
+ let authenticated = !loginArgs;
21
+ if (found && loginArgs) {
22
+ const key = `${found}:${loginArgs.join(' ')}`;
23
+ const cached = authenticationCache.get(key);
24
+ if (cached && cached.until > Date.now()) authenticated = cached.value;
25
+ else {
26
+ const check = spawnCommandSync(found, loginArgs, { encoding: 'utf8', timeout: 3000 });
27
+ authenticated = check.status === 0 && !/not logged in|not authenticated|login required/i.test(`${check.stdout || ''} ${check.stderr || ''}`);
28
+ authenticationCache.set(key, { value: authenticated, until: Date.now() + 60000 });
29
+ }
30
+ }
31
+ const available = Boolean(found && authenticated);
32
+ return { available, mode: 'local_cli', reason: available ? '' : found
33
+ ? `请先在本机登录 ${command}` : `本机未找到 ${command} 命令` };
34
+ }
35
+
36
+ /** 同步启动一个已解析到绝对路径的命令:.cmd/.bat 经 cmd.exe,其余直连。 */
37
+ function spawnCommandSync(resolved, args, options) {
38
+ return isWindows && /\.(?:cmd|bat)$/i.test(resolved)
39
+ ? spawnSync(windowsCommandLine([resolved, ...args]), { ...options, shell: true })
40
+ : spawnSync(resolved, args, options);
41
+ }
42
+
43
+ export function gitState(cwd) {
44
+ const hasHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], {
45
+ cwd, stdio: 'ignore', timeout: 5000,
46
+ }).status === 0;
47
+ const status = spawnSync('git', ['status', '--porcelain', '-z'], {
48
+ cwd, timeout: 5000, maxBuffer: 32 * 1024 * 1024,
49
+ });
50
+ const diff = spawnSync('git', hasHead
51
+ ? ['diff', '--no-ext-diff', '--binary', 'HEAD']
52
+ : ['diff', '--no-ext-diff', '--binary'], {
53
+ cwd, timeout: 5000, maxBuffer: 32 * 1024 * 1024,
54
+ });
55
+ const staged = hasHead ? null : spawnSync('git', ['diff', '--no-ext-diff', '--binary', '--cached'], {
56
+ cwd, timeout: 5000, maxBuffer: 32 * 1024 * 1024,
57
+ });
58
+ const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
59
+ cwd, timeout: 5000, maxBuffer: 32 * 1024 * 1024,
60
+ });
61
+ if (status.status !== 0 || diff.status !== 0 || untracked.status !== 0 ||
62
+ (staged && staged.status !== 0)) {
63
+ throw new Error('无法检查项目改动状态');
64
+ }
65
+ const hash = createHash('sha256').update(status.stdout).update(diff.stdout);
66
+ if (staged) hash.update(staged.stdout);
67
+ for (const relative of untracked.stdout.toString('utf8').split('\0').filter(Boolean)) {
68
+ const filename = path.join(cwd, relative);
69
+ const stat = fs.lstatSync(filename);
70
+ hash.update(relative);
71
+ if (stat.isFile() && stat.size <= 4 * 1024 * 1024) hash.update(fs.readFileSync(filename));
72
+ else hash.update(`${stat.size}:${stat.mtimeMs}`);
73
+ }
74
+ return {
75
+ dirty: status.stdout.length > 0,
76
+ snapshot: hash.digest('hex'),
77
+ // 改动清单直接从上面这次 `git status --porcelain -z` 里取,收尾时不必再跑一次 git:
78
+ // 少一个子进程,也少一分超时风险。porcelain 的条目本身就是「 XY 路径」这种可读形状。
79
+ changed: status.stdout.toString('utf8').split('\0').filter(Boolean).slice(0, 40).join('\n'),
80
+ };
81
+ }
82
+
83
+ function repositorySnapshot(cwd) {
84
+ if (!fs.existsSync(cwd)) {
85
+ fs.mkdirSync(cwd, { recursive: true });
86
+ spawnSync('git', ['init'], { cwd, encoding: 'utf8', timeout: 15000 });
87
+ }
88
+ if (!fs.statSync(cwd).isDirectory()) {
89
+ throw new Error(`代码目录不存在:${cwd}`);
90
+ }
91
+ const repo = spawnSync('git', ['rev-parse', '--show-toplevel'], {
92
+ cwd, encoding: 'utf8', timeout: 5000,
93
+ });
94
+ if (repo.status !== 0) {
95
+ throw new Error('目标项目必须是 Git 仓库,以便查看和恢复改动');
96
+ }
97
+ const root = repo.stdout.trim();
98
+ if (path.resolve(root) !== path.resolve(cwd)) {
99
+ throw new Error('请把项目路径设为 Git 仓库根目录');
100
+ }
101
+ return gitState(cwd).snapshot;
102
+ }
103
+
104
+ /** 收尾附上本轮改了什么:有改动就列出文件,没有就明说,别让用户去猜。 */
105
+ function changeFooter(state, message) {
106
+ return state.changed ? `${message('改动文件:')}\n${state.changed}` : message('没有检测到代码改动。');
107
+ }
108
+
109
+ export function runCli({ job, project, signal, onProgress, onSession, config, model }, adapter) {
110
+ const startingGitSnapshot = repositorySnapshot(project.path);
111
+ const message = (value) => translateKnownMessage(value, config.locale);
112
+ // 每轮都拼上权限边界:用户原文一字不改,边界文案按界面语言翻译。
113
+ const prompt = `${job.instruction}\n\n${message(GUARD_INSTRUCTION)}`;
114
+ const spec = adapter.command({ cwd: project.path, instruction: prompt, config, model, sessionId: job.sessionId });
115
+ return new Promise((resolve, reject) => {
116
+ const events = adapter.createEvents(onProgress, onSession);
117
+ // 命令先解析成绝对路径:Windows 上 codex/cursor 这类 CLI 实际是 .cmd,
118
+ // 直接 spawn 会 EINVAL;经 cmd.exe 启动,并自己把参数引号拼好。
119
+ const resolved = findCommand(spec.command) || spec.command;
120
+ const viaCmd = isWindows && /\.(?:cmd|bat)$/i.test(resolved);
121
+ const child = viaCmd
122
+ ? spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${windowsCommandLine([resolved, ...spec.args])}"`], {
123
+ cwd: project.path,
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ windowsVerbatimArguments: true,
126
+ env: process.env,
127
+ })
128
+ : spawn(resolved, spec.args, {
129
+ cwd: project.path,
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ detached: process.platform !== 'win32',
132
+ env: process.env,
133
+ });
134
+ let stderr = '';
135
+ let settled = false;
136
+ let timer;
137
+ const stop = () => {
138
+ if (child.pid && process.platform !== 'win32') {
139
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { /* Already exited. */ }
140
+ const forceTimer = setTimeout(() => {
141
+ if (child.exitCode === null) {
142
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* Already exited. */ }
143
+ }
144
+ }, 3000);
145
+ forceTimer.unref();
146
+ } else {
147
+ child.kill('SIGTERM');
148
+ }
149
+ };
150
+ const finish = (error, value) => {
151
+ if (settled) return;
152
+ settled = true;
153
+ clearTimeout(timer);
154
+ signal.removeEventListener('abort', stop);
155
+ if (error) reject(error); else resolve(value);
156
+ };
157
+ signal.addEventListener('abort', stop, { once: true });
158
+ if (signal.aborted) stop();
159
+ timer = setTimeout(() => {
160
+ stop();
161
+ finish(new Error('任务运行超过 20 分钟,已停止'));
162
+ }, 20 * 60 * 1000);
163
+ child.on('error', (error) => finish(error));
164
+ child.stdout.setEncoding('utf8');
165
+ child.stdout.on('data', (chunk) => events.write(chunk));
166
+ child.stderr.on('data', (chunk) => {
167
+ stderr = `${stderr}${chunk.toString('utf8')}`.slice(-8000);
168
+ });
169
+ child.on('close', (code) => {
170
+ if (settled) return;
171
+ try {
172
+ if (signal.aborted) return finish(new Error('任务已取消'));
173
+ const output = events.finish();
174
+ if (code !== 0) {
175
+ const classified = adapter.classifyExit?.({ job, project, stderr, output, startingGitSnapshot });
176
+ if (classified) return finish(classified);
177
+ return finish(new Error(`${adapter.label} 退出码 ${code}:${stderr.trim().slice(-300) || message(output.error || '请在电脑端检查 CLI 登录和权限设置')}`));
178
+ }
179
+ if (output.error) return finish(new Error(`${adapter.label}:${output.error}`));
180
+ const summary = output.hasAssistantText === false ? message(output.result) : output.result;
181
+ const state = gitState(project.path);
182
+ finish(null, {
183
+ status: 'completed',
184
+ result: `${String(summary).slice(-12000)}\n\n${changeFooter(state, message)}`,
185
+ gitSnapshot: state.snapshot,
186
+ });
187
+ } catch (error) { finish(error); }
188
+ });
189
+ child.stdin.end(spec.stdin || '');
190
+ });
191
+ }
@@ -0,0 +1,121 @@
1
+ import { builtinAgentIcons } from '../agent-icons.mjs';
2
+ import { translateKnownMessage } from '../i18n.mjs';
3
+ async function run({ job, config, signal }) {
4
+ if (!config.workbuddy?.allowUnscopedDispatch || !config.workbuddyToken) {
5
+ throw new Error('WorkBuddy 开放平台尚未授权或未开启转交模式');
6
+ }
7
+ const response = await fetch('https://www.workbuddy.cn/openapi/v2/localassistant/message', {
8
+ method: 'POST',
9
+ signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
10
+ headers: {
11
+ Authorization: `Bearer ${config.workbuddyToken}`,
12
+ 'Content-Type': 'application/json',
13
+ Accept: 'application/json',
14
+ },
15
+ body: JSON.stringify({ content: job.instruction, msg_type: 'text' }),
16
+ });
17
+ const data = await response.json();
18
+ if (!response.ok || data.code !== 0) {
19
+ throw new Error(`WorkBuddy 转交失败:${data.msg || response.status}`);
20
+ }
21
+ return {
22
+ status: 'handed_off',
23
+ result: config.locale === 'en'
24
+ ? `Sent to WorkBuddy. Local message ID: ${data.data?.message_id || 'unknown'}. Continue in WorkBuddy for execution and permission requests.`
25
+ : `已转交给 WorkBuddy,本机消息 ID:${data.data?.message_id || '未知'}。请在 WorkBuddy 中查看后续执行与权限请求。`,
26
+ handoff: { cursor: data.data?.message_id || '', readState: 'retrying', replyCount: 0 },
27
+ };
28
+ }
29
+
30
+ async function readMessages(job, config) {
31
+ if (!config.workbuddyToken || !job.handoff?.cursor) return { messages: [] };
32
+ const url = new URL('https://www.workbuddy.cn/openapi/v2/localassistant/message');
33
+ url.searchParams.set('message_id', job.handoff?.cursor);
34
+ const response = await fetch(url, {
35
+ method: 'GET',
36
+ signal: AbortSignal.timeout(15000),
37
+ headers: {
38
+ Authorization: `Bearer ${config.workbuddyToken}`,
39
+ Accept: 'application/json',
40
+ },
41
+ });
42
+ if (response.status === 401 || response.status === 403) {
43
+ return { unreadable: true };
44
+ }
45
+ const data = await response.json();
46
+ if (!response.ok || data.code !== 0) {
47
+ throw new Error(`WorkBuddy 消息读取失败:${data.msg || response.status}`);
48
+ }
49
+ if (!Array.isArray(data.data?.messages)) {
50
+ throw new Error('WorkBuddy 消息历史格式无效');
51
+ }
52
+ return {
53
+ messages: data.data.messages.slice(0, 100).map((message) => ({
54
+ id: typeof message.message_id === 'string' ? message.message_id : '',
55
+ role: message.role,
56
+ type: message.msg_type,
57
+ text: Array.isArray(message.content)
58
+ ? message.content.filter((part) => typeof part === 'string').join('\n').slice(0, 2000)
59
+ : '',
60
+ })),
61
+ };
62
+ }
63
+
64
+ // Migration aliases are confined to this builtin adapter so existing state and
65
+ // older desktop clients remain compatible without shaping the plugin protocol.
66
+ function syncJobState(job) {
67
+ if (!job.handoff && job.workbuddyCursor) {
68
+ job.handoff = { cursor: job.workbuddyCursor, readState: job.workbuddyReadState || 'retrying', replyCount: job.workbuddyReplyCount || 0 };
69
+ }
70
+ if (!job.handoff) return;
71
+ job.workbuddyMessageId ||= job.handoff.cursor || '';
72
+ job.workbuddyCursor = job.handoff.cursor;
73
+ job.workbuddyReadState = job.handoff.readState;
74
+ job.workbuddyReplyCount = job.handoff.replyCount || 0;
75
+ if (job.handoffReadError) job.workbuddyReadError = job.handoffReadError;
76
+ else delete job.workbuddyReadError;
77
+ }
78
+
79
+ async function readUpdates({ job, config }) {
80
+ const localize = (value) => translateKnownMessage(value, config.locale);
81
+ const update = await readMessages(job, config);
82
+ const handoff = { ...job.handoff };
83
+ const additions = [];
84
+ if (update.unreadable) {
85
+ handoff.readState = 'unavailable';
86
+ const notice = localize('当前授权不能读取 WorkBuddy 消息;需 user.localassistant.readable 权限。请在 WorkBuddy 中查看回复。');
87
+ if (!job.result?.includes(notice)) additions.push(notice);
88
+ } else {
89
+ handoff.readState = 'readable';
90
+ for (const message of update.messages || []) {
91
+ if (!message.id || message.id === handoff.cursor) continue;
92
+ // This API is a shared stream: another user prompt makes association
93
+ // ambiguous, so later replies must not be attached to this handoff.
94
+ if (message.role === 'user') {
95
+ handoff.readState = 'ambiguous';
96
+ additions.push(localize('出现其他 WorkBuddy 用户消息;后续回复无法可靠归属此任务,请在 WorkBuddy 中查看。'));
97
+ break;
98
+ }
99
+ handoff.cursor = message.id;
100
+ if (message.role !== 'assistant') continue;
101
+ const reply = message.text || (message.type === 'permission'
102
+ ? localize('助理请求进一步确认,请在 WorkBuddy 中处理。')
103
+ : localize('助理有一条新消息,请在 WorkBuddy 中查看详情。'));
104
+ additions.push(`${localize('WorkBuddy 后续助理消息(任务关联未验证):')}\n${reply}`);
105
+ handoff.replyCount = (handoff.replyCount || 0) + 1;
106
+ }
107
+ }
108
+ return { handoff, appendResult: additions.join('\n\n') };
109
+ }
110
+
111
+ export default {
112
+ apiVersion: 1, kind: 'coding-agent', id: 'workbuddy', label: 'WorkBuddy', icon: builtinAgentIcons.panda,
113
+ capabilities: { session: 'external_unscoped', model: false, cancel: false, progress: false },
114
+ probe(config) {
115
+ const available = Boolean(config.workbuddyToken && config.workbuddy?.allowUnscopedDispatch);
116
+ return { available, mode: 'dispatch_only', reason: available
117
+ ? '可转交任务;如获消息读取授权可回显回复,无法保证指定代码目录或撤回'
118
+ : '需 WorkBuddy 开放平台授权,并显式允许无项目目录保证的转交模式' };
119
+ },
120
+ run, readUpdates, syncJobState,
121
+ };
@@ -0,0 +1,266 @@
1
+ export const CAPABILITY_NAME = /^[a-z][a-z0-9_]{0,15}(?:\.[a-z][a-z0-9_]{0,15}){0,2}$/;
2
+ const FIELD_KEY = /^[a-z][a-z0-9_]{0,15}$/;
3
+ const COMMAND_ID = /^[1-9][0-9]{0,15}$/;
4
+ const MAX_ITEMS = 32;
5
+ const MAX_QUEUE = 8;
6
+ const MAX_FIELDS = 6;
7
+ const MAX_TEXT = 120;
8
+ const MAX_PER_SECOND = 8;
9
+ const MAX_AUDIO = 256 * 1024;
10
+ const PLUGIN_TIMEOUT_MS = 1500;
11
+
12
+ function fail(message) {
13
+ const error = new Error(message);
14
+ error.preserveMessage = true;
15
+ return error;
16
+ }
17
+
18
+ function readNames(list, { cadence }) {
19
+ if (!Array.isArray(list) || list.length > MAX_ITEMS) throw fail('能力清单无效');
20
+ const items = [];
21
+ const seen = new Set();
22
+ for (const item of list) {
23
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw fail('能力清单无效');
24
+ if (typeof item.name !== 'string' || !CAPABILITY_NAME.test(item.name) || seen.has(item.name)) throw fail('能力清单无效');
25
+ const rate = item.cadence || 'edge';
26
+ if (cadence && rate !== 'edge' && rate !== 'continuous') throw fail('能力清单无效');
27
+ if (!cadence && item.cadence !== undefined && item.cadence !== 'edge') throw fail('能力清单无效');
28
+ seen.add(item.name);
29
+ items.push(cadence ? { name: item.name, cadence: rate } : { name: item.name });
30
+ }
31
+ return items;
32
+ }
33
+
34
+ export function parseCatalog(payload) {
35
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw fail('能力清单无效');
36
+ return {
37
+ events: readNames(payload.events, { cadence: true }),
38
+ commands: readNames(payload.commands, { cadence: false }),
39
+ };
40
+ }
41
+
42
+ export function parseFields(fields) {
43
+ if (fields === undefined || fields === null) return {};
44
+ if (typeof fields !== 'object' || Array.isArray(fields)) throw fail('事件字段无效');
45
+ const keys = Object.keys(fields);
46
+ if (keys.length > MAX_FIELDS) throw fail('事件字段无效');
47
+ const out = {};
48
+ for (const key of keys) {
49
+ const value = fields[key];
50
+ if (!FIELD_KEY.test(key) || typeof value !== 'string' || Buffer.byteLength(value) > MAX_TEXT) throw fail('事件字段无效');
51
+ out[key] = value;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function pluginAvailable(plugin, config) {
57
+ try {
58
+ const result = plugin.probe(config);
59
+ return Boolean(result && typeof result === 'object' && !(result instanceof Promise) && result.available === true);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function commandsFrom(plugin, result, catalog) {
66
+ if (!result || typeof result !== 'object' || !Array.isArray(result.commands)) return [];
67
+ const allowed = new Set(plugin.commands);
68
+ const known = new Set(catalog.commands.map((item) => item.name));
69
+ const commands = [];
70
+ for (const command of result.commands) {
71
+ if (!command || typeof command !== 'object' || !allowed.has(command.name) || !known.has(command.name)) continue;
72
+ let fields;
73
+ try { fields = parseFields(command.fields); } catch { continue; }
74
+ commands.push({ name: command.name, fields });
75
+ }
76
+ return commands;
77
+ }
78
+
79
+ function pcmWav(audio) {
80
+ if (!Buffer.isBuffer(audio) || audio.length < 44 || audio.length > MAX_AUDIO) return false;
81
+ if (audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE') return false;
82
+ let offset = 12;
83
+ let format = 0;
84
+ let channels = 0;
85
+ let rate = 0;
86
+ let bits = 0;
87
+ let data = 0;
88
+ while (offset + 8 <= audio.length) {
89
+ const id = audio.toString('ascii', offset, offset + 4);
90
+ const size = audio.readUInt32LE(offset + 4);
91
+ if (id === 'fmt ' && offset + 24 <= audio.length) {
92
+ format = audio.readUInt16LE(offset + 8);
93
+ channels = audio.readUInt16LE(offset + 10);
94
+ rate = audio.readUInt32LE(offset + 12);
95
+ bits = audio.readUInt16LE(offset + 22);
96
+ } else if (id === 'data') data = size;
97
+ const step = 8 + size + (size & 1);
98
+ if (step > audio.length) return false;
99
+ offset += step;
100
+ }
101
+ return format === 1 && bits === 16 && channels >= 1 && channels <= 2 && rate >= 8000 && rate <= 48000 && data > 0;
102
+ }
103
+
104
+ function enqueueCommand(queues, audios, catalogs, seq, deviceId, command, allowed, audio) {
105
+ const catalog = catalogs.get(deviceId);
106
+ if (!catalog) throw fail('板侧尚未上报命令清单');
107
+ if (!command || typeof command !== 'object' || typeof command.name !== 'string' || !CAPABILITY_NAME.test(command.name)) throw fail('命令无效');
108
+ if (!catalog.commands.some((item) => item.name === command.name)) throw fail('命令不在板侧清单');
109
+ if (allowed && !allowed.has(command.name)) throw fail('插件未声明该命令');
110
+ const fields = parseFields(command.fields);
111
+ if (audio != null && command.name !== 'audio.play') throw fail('只有 audio.play 可以附带语音');
112
+ if (command.name === 'audio.play' && audio == null) throw fail('audio.play 需要同时附带语音');
113
+ if (audio != null && !pcmWav(audio)) throw fail('语音须为不超过 256KB 的 16-bit PCM WAV');
114
+ const queue = queues.get(deviceId) || [];
115
+ if (queue.length >= MAX_QUEUE) throw fail('命令队列已满');
116
+ if (audio != null && queue.some((item) => item.audio)) throw fail('已有一条待播放语音');
117
+ seq.n += 1;
118
+ const item = { id: String(seq.n), name: command.name, fields, audio: audio != null };
119
+ queue.push(item);
120
+ queues.set(deviceId, queue);
121
+ if (audio != null) audios.set(`${deviceId}\n${item.id}`, audio);
122
+ return { id: item.id, name: item.name, fields: { ...item.fields }, audio: item.audio };
123
+ }
124
+
125
+ async function runPlugin(plugin, context) {
126
+ let timer;
127
+ try {
128
+ return await Promise.race([
129
+ Promise.resolve(plugin.onEvent(context)),
130
+ new Promise((_, reject) => {
131
+ timer = setTimeout(() => reject(fail('插件处理超时')), PLUGIN_TIMEOUT_MS);
132
+ }),
133
+ ]);
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+
139
+ export function createBoardLink() {
140
+ const catalogs = new Map();
141
+ const queues = new Map();
142
+ const audios = new Map();
143
+ const hits = new Map();
144
+ const seq = { n: 0 };
145
+ const dropAudio = (deviceId, id) => audios.delete(`${deviceId}\n${id}`);
146
+ const enqueue = (deviceId, command, allowed, audio) => enqueueCommand(queues, audios, catalogs, seq, deviceId, command, allowed, audio);
147
+ return {
148
+ setCatalog(deviceId, payload) {
149
+ const catalog = parseCatalog(payload);
150
+ catalogs.set(deviceId, catalog);
151
+ const known = new Set(catalog.commands.map((item) => item.name));
152
+ const queue = queues.get(deviceId) || [];
153
+ const next = queue.filter((item) => known.has(item.name));
154
+ for (const item of queue) {
155
+ if (!next.includes(item)) dropAudio(deviceId, item.id);
156
+ }
157
+ queues.set(deviceId, next);
158
+ return catalog;
159
+ },
160
+ forget(deviceId) {
161
+ catalogs.delete(deviceId);
162
+ queues.delete(deviceId);
163
+ for (const key of [...hits.keys(), ...audios.keys()]) {
164
+ if (key.startsWith(`${deviceId}\n`)) {
165
+ hits.delete(key);
166
+ audios.delete(key);
167
+ }
168
+ }
169
+ },
170
+ pending(deviceId) {
171
+ return (queues.get(deviceId) || []).map((item) => ({
172
+ id: item.id, name: item.name, fields: { ...item.fields }, audio: item.audio === true,
173
+ }));
174
+ },
175
+ audio(deviceId, id) {
176
+ const wav = audios.get(`${deviceId}\n${id}`);
177
+ return wav ? Buffer.from(wav) : null;
178
+ },
179
+ send(deviceId, command, audio) {
180
+ return enqueue(deviceId, command, undefined, audio);
181
+ },
182
+ deviceIds() {
183
+ return [...catalogs.keys()];
184
+ },
185
+ connect(deviceId, runtime, config, bridge) {
186
+ if (!runtime?.boardPlugins) return;
187
+ for (const plugin of runtime.boardPlugins()) {
188
+ if (typeof plugin.onConnect !== 'function' || !pluginAvailable(plugin, config)) continue;
189
+ const allowed = new Set(plugin.commands);
190
+ const send = (command, audio) => enqueue(deviceId, command, allowed, audio);
191
+ try {
192
+ const result = plugin.onConnect({ deviceId, config, send, bridge });
193
+ if (result && typeof result.then === 'function') result.catch(() => {});
194
+ } catch {
195
+ /* A plugin that fails to arm must not block the catalog response. */
196
+ }
197
+ }
198
+ },
199
+ ack(deviceId, payload) {
200
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray(payload.ids) || payload.ids.length > MAX_QUEUE) throw fail('确认无效');
201
+ const drop = new Set();
202
+ for (const id of payload.ids) {
203
+ if (typeof id !== 'string' || !COMMAND_ID.test(id) || drop.has(id)) throw fail('确认无效');
204
+ drop.add(id);
205
+ }
206
+ const queue = queues.get(deviceId) || [];
207
+ const next = queue.filter((item) => !drop.has(item.id));
208
+ for (const id of drop) dropAudio(deviceId, id);
209
+ queues.set(deviceId, next);
210
+ return { acked: queue.length - next.length };
211
+ },
212
+ subscription(deviceId, runtime, config) {
213
+ const catalog = catalogs.get(deviceId);
214
+ if (!catalog || !runtime?.boardPlugins) return [];
215
+ const known = new Set(catalog.events.map((item) => item.name));
216
+ const wanted = [];
217
+ for (const plugin of runtime.boardPlugins()) {
218
+ if (!pluginAvailable(plugin, config)) continue;
219
+ for (const name of plugin.events) {
220
+ if (known.has(name) && !wanted.includes(name)) wanted.push(name);
221
+ }
222
+ }
223
+ return wanted;
224
+ },
225
+ async handleEvent(deviceId, payload, runtime, config, bridge) {
226
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw fail('事件无效');
227
+ if (typeof payload.name !== 'string' || !CAPABILITY_NAME.test(payload.name)) throw fail('事件无效');
228
+ const fields = parseFields(payload.fields);
229
+ const catalog = catalogs.get(deviceId);
230
+ if (!catalog || !catalog.events.some((item) => item.name === payload.name)) throw fail('事件不在板侧清单');
231
+ const subscribed = this.subscription(deviceId, runtime, config);
232
+ if (!subscribed.includes(payload.name)) throw fail('事件未注册');
233
+ const key = `${deviceId}\n${payload.name}`;
234
+ const now = Date.now();
235
+ const recent = (hits.get(key) || []).filter((at) => now - at < 1000);
236
+ if (recent.length >= MAX_PER_SECOND) throw fail('事件上报过于频繁');
237
+ recent.push(now);
238
+ hits.set(key, recent);
239
+ const allowedFor = (plugin) => new Set(plugin.commands);
240
+ for (const plugin of runtime.boardPlugins()) {
241
+ if (!plugin.events.includes(payload.name) || !pluginAvailable(plugin, config)) continue;
242
+ const allowed = allowedFor(plugin);
243
+ let result;
244
+ try {
245
+ result = await runPlugin(plugin, {
246
+ event: { name: payload.name, fields },
247
+ config,
248
+ deviceId,
249
+ send: (command, audio) => enqueue(deviceId, command, allowed, audio),
250
+ bridge,
251
+ });
252
+ } catch {
253
+ continue;
254
+ }
255
+ for (const command of commandsFrom(plugin, result, catalog)) {
256
+ try {
257
+ enqueue(deviceId, command, allowed);
258
+ } catch (error) {
259
+ if (error.message === '命令队列已满') break;
260
+ }
261
+ }
262
+ }
263
+ return { accepted: true };
264
+ },
265
+ };
266
+ }