made-bridge 0.0.0-stage → 0.2.0

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 +939 -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,134 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { builtinAgent } from './agents/index.mjs';
5
+
6
+ export const VIBE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ export const CLI_SESSION_ID = /^[A-Za-z0-9_-]{6,128}$/;
8
+ const PROVIDER_ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
9
+ const OPAQUE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
10
+
11
+ export function validAgentSessionId(plugin, value) {
12
+ if (typeof value !== 'string' || value.length > 256) return false;
13
+ const pattern = plugin?.sessionIdPattern || OPAQUE_SESSION_ID;
14
+ pattern.lastIndex = 0;
15
+ return pattern.test(value);
16
+ }
17
+ const DEFAULT_TITLE = '新任务';
18
+
19
+ function now() { return new Date().toISOString(); }
20
+
21
+ function titleFrom(value) {
22
+ if (typeof value !== 'string') throw new Error('任务标题必须是文字');
23
+ const title = value.replace(/[\x00-\x1f\x7f]+/g, ' ').trim();
24
+ if (!title || Array.from(title).length > 60) throw new Error('任务标题需在 1 到 60 字之间');
25
+ return title;
26
+ }
27
+
28
+ function firstTurnTitle(instruction) {
29
+ const compact = String(instruction).replace(/\s+/g, ' ').trim();
30
+ const chars = Array.from(compact);
31
+ return chars.slice(0, 24).join('') + (chars.length > 24 ? '…' : '');
32
+ }
33
+
34
+ export class SessionStore {
35
+ constructor({ file, projects, defaultProject, defaultProvider, pluginsRuntime, agent }) {
36
+ this.agent = agent || (pluginsRuntime ? (id) => pluginsRuntime.agent(id) : builtinAgent);
37
+ this.file = file;
38
+ this.projects = projects;
39
+ this.defaultProject = defaultProject;
40
+ this.defaultProvider = defaultProvider;
41
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
42
+ const parsed = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : [];
43
+ this.sessions = Array.isArray(parsed) ? parsed : [];
44
+ let migrated = false;
45
+ for (const session of this.sessions) {
46
+ if (session.autoTitlePending && session.title === '新会话') {
47
+ session.title = DEFAULT_TITLE;
48
+ migrated = true;
49
+ }
50
+ }
51
+ if (migrated) this.save();
52
+ }
53
+
54
+ save() {
55
+ const temp = `${this.file}.${process.pid}.tmp`;
56
+ fs.writeFileSync(temp, JSON.stringify(this.sessions, null, 2), { mode: 0o600 });
57
+ fs.renameSync(temp, this.file);
58
+ }
59
+
60
+ list(provider) {
61
+ return this.sessions.filter((session) => !provider || session.provider === provider)
62
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
63
+ }
64
+
65
+ get(id) { return this.sessions.find((session) => session.id === id); }
66
+
67
+ create({ provider, projectId, title, legacy = false, cliSessionId = '' } = {}) {
68
+ const selectedProvider = provider || this.defaultProvider;
69
+ const selectedProject = projectId || this.defaultProject;
70
+ const plugin = this.agent(selectedProvider);
71
+ if (!plugin && !(legacy && PROVIDER_ID.test(selectedProvider))) throw new Error('不支持的编程工具');
72
+ if (!this.projects.some((project) => project.id === selectedProject)) {
73
+ throw new Error('项目不在本机允许列表中');
74
+ }
75
+ const explicitTitle = title !== undefined && title !== null;
76
+ const timestamp = now();
77
+ const session = {
78
+ id: randomUUID(), provider: selectedProvider, projectId: selectedProject,
79
+ title: explicitTitle ? titleFrom(title) : DEFAULT_TITLE,
80
+ autoTitlePending: !explicitTitle,
81
+ contextMode: plugin?.capabilities.session || 'native',
82
+ cliSessionId: validAgentSessionId(plugin, cliSessionId) ? cliSessionId : '',
83
+ legacy, createdAt: timestamp, updatedAt: timestamp,
84
+ };
85
+ this.sessions.push(session);
86
+ this.save();
87
+ return session;
88
+ }
89
+
90
+ rename(id, title) {
91
+ const session = this.get(id);
92
+ if (!session) throw new Error('任务不存在');
93
+ session.title = titleFrom(title);
94
+ session.autoTitlePending = false;
95
+ session.updatedAt = now();
96
+ this.save();
97
+ return session;
98
+ }
99
+
100
+ delete(id) {
101
+ const index = this.sessions.findIndex((session) => session.id === id);
102
+ if (index < 0) throw new Error('任务不存在');
103
+ this.sessions.splice(index, 1);
104
+ this.save();
105
+ }
106
+
107
+ recordTurn(id, instruction) {
108
+ const session = this.get(id);
109
+ if (!session) throw new Error('任务不存在');
110
+ if (session.autoTitlePending) {
111
+ session.title = firstTurnTitle(instruction);
112
+ session.autoTitlePending = false;
113
+ }
114
+ session.updatedAt = now();
115
+ this.save();
116
+ return session;
117
+ }
118
+
119
+ setCliSession(id, cliSessionId) {
120
+ const session = this.get(id);
121
+ if (!session || !validAgentSessionId(this.agent(session.provider), cliSessionId)) return;
122
+ session.cliSessionId = cliSessionId;
123
+ session.updatedAt = now();
124
+ this.save();
125
+ }
126
+
127
+ setGitSnapshot(id, snapshot) {
128
+ const session = this.get(id);
129
+ if (!session || typeof snapshot !== 'string') return;
130
+ session.gitSnapshot = snapshot;
131
+ session.updatedAt = now();
132
+ this.save();
133
+ }
134
+ }
@@ -0,0 +1,10 @@
1
+ import openai from './openai.mjs';
2
+ import whisper from './whisper.mjs';
3
+
4
+ const off = {
5
+ apiVersion: 1, kind: 'speech-recognizer', id: 'off', label: '关闭语音识别', fields: [],
6
+ probe: () => ({ available: false, reason: '语音识别已关闭' }),
7
+ transcribe: async () => '',
8
+ };
9
+
10
+ export const builtinSpeechRecognizers = [openai, whisper, off];
@@ -0,0 +1,48 @@
1
+ import { DEFAULT_TRANSCRIBE_MODEL, DEFAULT_TRANSCRIBE_URL, validateTranscribeModel, validateTranscribeUrl } from '../voice-settings.mjs';
2
+ import { validateOpenAIKey } from '../openai-key.mjs';
3
+
4
+ export default {
5
+ apiVersion: 1,
6
+ kind: 'speech-recognizer',
7
+ id: 'openai',
8
+ label: 'OpenAI 兼容语音接口',
9
+ fields: [
10
+ { key: 'url', label: '转写接口 URL', type: 'url', required: true, default: DEFAULT_TRANSCRIBE_URL },
11
+ { key: 'apiKey', label: 'API Key', type: 'secret', required: true },
12
+ { key: 'model', label: '模型名', type: 'model', required: true, default: DEFAULT_TRANSCRIBE_MODEL },
13
+ ],
14
+ presets: [
15
+ { id: 'openai', label: 'OpenAI', values: { url: DEFAULT_TRANSCRIBE_URL, model: DEFAULT_TRANSCRIBE_MODEL } },
16
+ { id: 'siliconflow', label: '硅基流动', values: { url: 'https://api.siliconflow.cn/v1/audio/transcriptions', model: 'FunAudioLLM/SenseVoiceSmall' } },
17
+ ],
18
+ validateSettings(settings) {
19
+ return { ...settings, url: validateTranscribeUrl(settings.url),
20
+ model: validateTranscribeModel(settings.model), apiKey: validateOpenAIKey(settings.apiKey || '') };
21
+ },
22
+ probe(settings) {
23
+ return { available: Boolean(settings.apiKey), reason: settings.apiKey ? '' : '请在电脑页面配置语音 API Key' };
24
+ },
25
+ async transcribe({ wav, settings, signal }) {
26
+ if (!settings.apiKey) throw new Error('请在电脑页面配置语音 API Key');
27
+ const data = new FormData();
28
+ data.set('model', settings.model);
29
+ // Let the service detect speech language; interface locale is independent.
30
+ data.set('file', new Blob([wav], { type: 'audio/wav' }), 'voice.wav');
31
+ let response;
32
+ try {
33
+ response = await fetch(settings.url, {
34
+ method: 'POST', signal: AbortSignal.any([AbortSignal.timeout(90000), signal]),
35
+ headers: { Authorization: `Bearer ${settings.apiKey}` }, body: data, redirect: 'error',
36
+ });
37
+ } catch {
38
+ signal.throwIfAborted();
39
+ throw new Error('无法连接语音识别服务,请检查电脑侧接口 URL 和网络');
40
+ }
41
+ if (!response.ok) throw new Error(`语音识别服务返回 ${response.status};请检查电脑侧 URL、API Key 和模型`);
42
+ let result;
43
+ try { result = await response.json(); }
44
+ catch { throw new Error('语音识别服务未返回 JSON 结果'); }
45
+ // The host checks type and transcript bounds for every recognizer.
46
+ return result?.text;
47
+ },
48
+ };
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ function run(command, args, signal) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'], signal });
9
+ let stderr = '';
10
+ let killTimer;
11
+ const killEventually = () => {
12
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
13
+ killTimer.unref();
14
+ };
15
+ signal.addEventListener('abort', killEventually, { once: true });
16
+ child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-1500); });
17
+ child.on('error', reject);
18
+ child.on('close', (code) => {
19
+ clearTimeout(killTimer);
20
+ signal.removeEventListener('abort', killEventually);
21
+ if (signal.aborted) reject(signal.reason);
22
+ else if (code === 0) resolve();
23
+ else reject(new Error(`本地语音识别失败(退出码 ${code}):${stderr}`));
24
+ });
25
+ });
26
+ }
27
+
28
+ export default {
29
+ apiVersion: 1,
30
+ kind: 'speech-recognizer',
31
+ id: 'whisper',
32
+ label: '本机 Whisper',
33
+ fields: [
34
+ { key: 'command', label: 'Whisper 程序', type: 'path', required: true, default: 'whisper-cli' },
35
+ { key: 'modelPath', label: '本机模型路径', type: 'path', required: true, placeholder: '/path/to/ggml-model.bin' },
36
+ ],
37
+ probe(settings) {
38
+ const available = Boolean(settings.modelPath && fs.existsSync(settings.modelPath));
39
+ return { available, reason: available ? '' : '请先选择已下载到电脑的 Whisper 模型' };
40
+ },
41
+ async transcribe({ wav, settings, signal }) {
42
+ if (!settings.modelPath || !fs.existsSync(settings.modelPath)) throw new Error('请先选择已下载到电脑的 Whisper 模型');
43
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-voice-'));
44
+ const audioPath = path.join(directory, 'voice.wav');
45
+ const outputBase = path.join(directory, 'transcript');
46
+ try {
47
+ fs.writeFileSync(audioPath, wav, { mode: 0o600 });
48
+ await run(settings.command || 'whisper-cli', [
49
+ '-m', settings.modelPath, '-f', audioPath, '-l', 'auto', '-nt', '-otxt', '-of', outputBase,
50
+ ], signal);
51
+ const transcriptPath = `${outputBase}.txt`;
52
+ if (!fs.existsSync(transcriptPath)) throw new Error('本地识别未生成文字');
53
+ if (fs.statSync(transcriptPath).size > 16000) throw new Error('本地识别文字过长,请重试');
54
+ return fs.readFileSync(transcriptPath, 'utf8');
55
+ } finally {
56
+ fs.rmSync(directory, { recursive: true, force: true });
57
+ }
58
+ },
59
+ };
@@ -0,0 +1,209 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { builtinSpeechRecognizers } from './speech/index.mjs';
5
+ import { DEFAULT_TRANSCRIBE_MODEL, DEFAULT_TRANSCRIBE_URL, saveVoiceSettings } from './voice-settings.mjs';
6
+ import { saveOpenAIKey } from './openai-key.mjs';
7
+
8
+ const SETTINGS_VERSION = 1;
9
+ const own = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key);
10
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const settingsPath = (stateDir) => path.join(stateDir, 'secrets', 'speech-settings.json');
12
+
13
+ export function speechRecognizers(config) {
14
+ return config.pluginsRuntime?.speechRecognizers() || builtinSpeechRecognizers;
15
+ }
16
+
17
+ export function speechRecognizer(config, id) {
18
+ return config.pluginsRuntime ? config.pluginsRuntime.speechRecognizer(id) : builtinSpeechRecognizers.find((plugin) => plugin.id === id);
19
+ }
20
+
21
+ function legacySettings(config) {
22
+ return {
23
+ openai: {
24
+ url: config.transcribeUrl || DEFAULT_TRANSCRIBE_URL,
25
+ model: config.transcribeModel || DEFAULT_TRANSCRIBE_MODEL,
26
+ apiKey: config.openaiKeySource === 'environment' ? '' : (config.openaiKey || ''),
27
+ },
28
+ whisper: { command: config.whisperCli || 'whisper-cli', modelPath: config.whisperModel || '' },
29
+ off: {},
30
+ };
31
+ }
32
+
33
+ function defaultSettings(plugin) {
34
+ return Object.fromEntries(plugin.fields.map((field) => [field.key, field.default ?? '']));
35
+ }
36
+
37
+ function validateFields(plugin, settings, required) {
38
+ if (!isRecord(settings)) throw new Error('语音插件设置格式无效');
39
+ const keys = new Set(plugin.fields.map((field) => field.key));
40
+ for (const key of Object.keys(settings)) if (!keys.has(key)) throw new Error(`语音插件不支持设置项:${key}`);
41
+ for (const field of plugin.fields) {
42
+ const value = settings[field.key];
43
+ if (typeof value !== 'string' || value.length > (field.type === 'model' ? 256 : 4096) || /[\x00-\x1f\x7f]/.test(value)) {
44
+ throw new Error(`${field.label} 格式无效`);
45
+ }
46
+ if (required && field.required && !value.trim()) throw new Error(`请先填写${field.label}`);
47
+ if (field.type === 'url' && value) {
48
+ let parsed;
49
+ try { parsed = new URL(value); } catch { throw new Error(`${field.label} URL 无效`); }
50
+ if (value !== value.trim() || !['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) {
51
+ throw new Error(`${field.label} 须使用不含账号、密码和片段的 HTTP 或 HTTPS 地址`);
52
+ }
53
+ }
54
+ }
55
+ return settings;
56
+ }
57
+
58
+ export function normalizeSpeechSettings(plugin, values, { required = false } = {}) {
59
+ let settings;
60
+ let normalized;
61
+ try {
62
+ if (!isRecord(values)) throw new Error('语音插件设置格式无效');
63
+ settings = validateFields(plugin, { ...defaultSettings(plugin), ...values }, required);
64
+ if (!plugin.validateSettings) return settings;
65
+ normalized = plugin.validateSettings({ ...settings });
66
+ // A plugin can normalize its fields, but cannot bypass host bounds or add fields.
67
+ return validateFields(plugin, normalized, required);
68
+ } catch (error) {
69
+ const message = redactSpeechError(error, plugin, settings || values || {});
70
+ throw new Error(redactSpeechError(new Error(message), plugin, normalized || {}));
71
+ }
72
+ }
73
+
74
+ export function initializeSpeechSettings(config) {
75
+ let saved;
76
+ try {
77
+ const file = settingsPath(config.stateDir);
78
+ if (!fs.lstatSync(file).isFile()) throw new Error('语音插件设置文件无效');
79
+ saved = JSON.parse(fs.readFileSync(file, 'utf8'));
80
+ if (saved.version !== SETTINGS_VERSION || typeof saved.pluginId !== 'string' || !isRecord(saved.settingsByPlugin)) {
81
+ throw new Error('语音插件设置文件格式无效');
82
+ }
83
+ } catch (error) {
84
+ if (error.code !== 'ENOENT') throw error;
85
+ }
86
+ const settingsByPlugin = { ...legacySettings(config), ...(saved?.settingsByPlugin || {}) };
87
+ // Keep settings of temporarily uninstalled plugins so reinstalling is lossless.
88
+ for (const plugin of speechRecognizers(config)) {
89
+ if (own(settingsByPlugin, plugin.id)) settingsByPlugin[plugin.id] = normalizeSpeechSettings(plugin, settingsByPlugin[plugin.id]);
90
+ }
91
+ config.speech = { pluginId: saved?.pluginId || config.asrMode || 'openai', settingsByPlugin };
92
+ syncLegacyFields(config);
93
+ return config.speech;
94
+ }
95
+
96
+ export function speechSelection(config, pluginId = config.speech?.pluginId || config.asrMode || 'openai') {
97
+ const plugin = speechRecognizer(config, pluginId);
98
+ if (!plugin) return { pluginId, plugin: null, settings: {} };
99
+ const stored = config.speech?.settingsByPlugin?.[pluginId] || legacySettings(config)[pluginId] || {};
100
+ const settings = { ...defaultSettings(plugin), ...stored };
101
+ if (pluginId === 'openai' && !settings.apiKey) {
102
+ settings.apiKey = config.environmentOpenaiKey || (!config.speech && config.openaiKey) || '';
103
+ }
104
+ return { pluginId, plugin, settings };
105
+ }
106
+
107
+ function syncLegacyFields(config) {
108
+ const openai = speechSelection(config, 'openai').settings;
109
+ const whisper = speechSelection(config, 'whisper').settings;
110
+ config.asrMode = config.speech.pluginId;
111
+ config.transcribeUrl = openai.url || DEFAULT_TRANSCRIBE_URL;
112
+ config.transcribeModel = openai.model || DEFAULT_TRANSCRIBE_MODEL;
113
+ config.openaiKey = openai.apiKey || '';
114
+ config.openaiKeySource = config.speech.settingsByPlugin.openai?.apiKey ? 'saved' : config.openaiKey ? 'environment' : 'none';
115
+ config.whisperCli = whisper.command || 'whisper-cli';
116
+ config.whisperModel = whisper.modelPath || '';
117
+ }
118
+
119
+ export function saveSpeechSettings(config, payload) {
120
+ if (!isRecord(payload) || typeof payload.pluginId !== 'string') throw new Error('请选择语音识别插件');
121
+ const plugin = speechRecognizer(config, payload.pluginId);
122
+ if (!plugin) throw new Error('未安装所选语音识别插件');
123
+ if (!isRecord(payload.settings || {})) throw new Error('语音插件设置格式无效');
124
+ const clearSecrets = payload.clearSecrets || [];
125
+ if (!Array.isArray(clearSecrets) || clearSecrets.some((key) => !plugin.fields.some((field) => field.key === key && field.type === 'secret'))) {
126
+ throw new Error('要清除的语音密钥字段无效');
127
+ }
128
+ const current = config.speech || { pluginId: config.asrMode || 'openai', settingsByPlugin: legacySettings(config) };
129
+ const updated = { ...defaultSettings(plugin), ...(current.settingsByPlugin[payload.pluginId] || {}) };
130
+ for (const [key, value] of Object.entries(payload.settings || {})) {
131
+ const field = plugin.fields.find((entry) => entry.key === key);
132
+ if (!field) throw new Error(`语音插件不支持设置项:${key}`);
133
+ // Blank passwords are placeholders in the UI, not requests to erase them.
134
+ if (field.type === 'secret' && value === '') continue;
135
+ updated[key] = value;
136
+ }
137
+ for (const key of clearSecrets) {
138
+ if (payload.settings?.[key]) throw new Error('不能同时设置和清除同一语音密钥');
139
+ updated[key] = '';
140
+ }
141
+ const normalized = normalizeSpeechSettings(plugin, updated);
142
+ const next = { pluginId: payload.pluginId, settingsByPlugin: { ...current.settingsByPlugin, [payload.pluginId]: normalized } };
143
+ const directory = path.join(config.stateDir, 'secrets');
144
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
145
+ fs.chmodSync(directory, 0o700);
146
+ const file = settingsPath(config.stateDir);
147
+ const temporary = path.join(directory, `.speech-settings-${process.pid}-${randomUUID()}`);
148
+ try {
149
+ fs.writeFileSync(temporary, JSON.stringify({ version: SETTINGS_VERSION, ...next }), { flag: 'wx', mode: 0o600 });
150
+ fs.renameSync(temporary, file);
151
+ fs.chmodSync(file, 0o600);
152
+ } finally {
153
+ fs.rmSync(temporary, { force: true });
154
+ }
155
+ config.speech = next;
156
+ syncLegacyFields(config);
157
+ // Preserve compatibility with existing integrations and older bridge versions.
158
+ if (payload.pluginId === 'openai') {
159
+ saveVoiceSettings(config.stateDir, { url: normalized.url, model: normalized.model });
160
+ saveOpenAIKey(config.stateDir, normalized.apiKey || '');
161
+ }
162
+ return speechSettingsState(config);
163
+ }
164
+
165
+ export function redactSpeechError(error, plugin, settings) {
166
+ let message = error instanceof Error ? error.message : '语音识别插件执行失败';
167
+ for (const field of plugin?.fields || []) {
168
+ if (field.type === 'secret' && typeof settings[field.key] === 'string' && settings[field.key]) message = message.split(settings[field.key]).join('[已隐藏密钥]');
169
+ }
170
+ return message.slice(0, 500);
171
+ }
172
+
173
+ function pluginState(config, plugin) {
174
+ const { settings } = speechSelection(config, plugin.id);
175
+ const publicSettings = {};
176
+ const secretConfigured = {};
177
+ for (const field of plugin.fields) {
178
+ if (field.type === 'secret') secretConfigured[field.key] = Boolean(settings[field.key]);
179
+ else publicSettings[field.key] = settings[field.key];
180
+ }
181
+ let probe;
182
+ try {
183
+ normalizeSpeechSettings(plugin, settings, { required: true });
184
+ probe = plugin.probe(settings, { config });
185
+ if (probe && typeof probe.then === 'function') {
186
+ Promise.resolve(probe).catch(() => {});
187
+ throw new Error('语音插件 probe 必须同步返回 available');
188
+ }
189
+ if (!probe || typeof probe.available !== 'boolean') throw new Error('语音插件 probe 必须同步返回 available');
190
+ } catch (error) {
191
+ probe = { available: false, reason: redactSpeechError(error, plugin, settings) };
192
+ }
193
+ return {
194
+ id: plugin.id, label: plugin.label,
195
+ fields: plugin.fields.map(({ key, label, type, required, default: defaultValue, placeholder }) => ({ key, label, type, required, ...(type === 'secret' ? {} : { default: defaultValue }), placeholder })),
196
+ presets: (plugin.presets || []).map(({ id, label, values }) => ({ id, label, values: Object.fromEntries(Object.entries(values).filter(([key]) => plugin.fields.some((field) => field.key === key && field.type !== 'secret'))) })),
197
+ settings: publicSettings, secretConfigured,
198
+ available: probe.available,
199
+ reason: typeof probe.reason === 'string' ? redactSpeechError(new Error(probe.reason), plugin, settings) : '',
200
+ };
201
+ }
202
+
203
+ export function speechSettingsState(config) {
204
+ const pluginId = config.speech?.pluginId || config.asrMode || 'openai';
205
+ const plugins = speechRecognizers(config).map((plugin) => pluginState(config, plugin));
206
+ const selected = plugins.find((plugin) => plugin.id === pluginId);
207
+ return { pluginId, plugins, settings: selected?.settings || {}, secretConfigured: selected?.secretConfigured || {},
208
+ available: selected?.available || false, reason: selected?.reason || (selected ? '' : '所选语音插件尚未安装') };
209
+ }
@@ -0,0 +1,85 @@
1
+ import { DEFAULT_TRANSCRIBE_MODEL, DEFAULT_TRANSCRIBE_URL } from './voice-settings.mjs';
2
+ import { normalizeSpeechSettings, redactSpeechError, speechSelection, speechSettingsState } from './speech-settings.mjs';
3
+
4
+ const MAX_WAV_BYTES = 1_100_000;
5
+
6
+ export function validateVoiceWav(wav) {
7
+ if (!Buffer.isBuffer(wav) || wav.length < 44 || wav.length > MAX_WAV_BYTES) {
8
+ throw new Error('录音大小不符合要求(最多约 30 秒)');
9
+ }
10
+ if (wav.toString('ascii', 0, 4) !== 'RIFF' ||
11
+ wav.readUInt32LE(4) !== wav.length - 8 ||
12
+ wav.toString('ascii', 8, 12) !== 'WAVE' ||
13
+ wav.toString('ascii', 12, 16) !== 'fmt ' ||
14
+ wav.readUInt32LE(16) !== 16 ||
15
+ wav.readUInt16LE(20) !== 1 ||
16
+ wav.readUInt16LE(22) !== 1 ||
17
+ wav.readUInt32LE(24) !== 16000 ||
18
+ wav.readUInt32LE(28) !== 32000 ||
19
+ wav.readUInt16LE(32) !== 2 ||
20
+ wav.readUInt16LE(34) !== 16 ||
21
+ wav.toString('ascii', 36, 40) !== 'data') {
22
+ throw new Error('录音必须是 16 kHz、16 bit、单声道 PCM WAV');
23
+ }
24
+ const size = wav.readUInt32LE(40);
25
+ if (size !== wav.length - 44 || size < 16000 || size > 16000 * 2 * 30) {
26
+ throw new Error('录音内容长度无效(需 0.5 到 30 秒)');
27
+ }
28
+ return wav;
29
+ }
30
+
31
+
32
+ export function transcriptionState(config) {
33
+ return {
34
+ ...speechSettingsState(config),
35
+ mode: config.speech?.pluginId || config.asrMode || 'openai',
36
+ keySource: config.openaiKeySource || (config.openaiKey ? 'environment' : 'none'),
37
+ url: config.transcribeUrl || DEFAULT_TRANSCRIBE_URL,
38
+ model: config.transcribeModel || DEFAULT_TRANSCRIBE_MODEL,
39
+ };
40
+ }
41
+
42
+ // A plugin receives an abort signal, and cancellation also stops waiting even if
43
+ // an extension accidentally ignores it. The extension must release its own I/O.
44
+ async function invoke(plugin, options) {
45
+ options.signal.throwIfAborted();
46
+ let aborted;
47
+ const cancellation = new Promise((_resolve, reject) => {
48
+ aborted = () => reject(options.signal.reason);
49
+ options.signal.addEventListener('abort', aborted, { once: true });
50
+ });
51
+ try {
52
+ return await Promise.race([Promise.resolve().then(() => {
53
+ options.signal.throwIfAborted();
54
+ return plugin.transcribe(options);
55
+ }), cancellation]);
56
+ } finally {
57
+ options.signal.removeEventListener('abort', aborted);
58
+ }
59
+ }
60
+
61
+ export async function transcribe(wav, config, signal) {
62
+ signal?.throwIfAborted();
63
+ validateVoiceWav(wav);
64
+ const selection = speechSelection(config);
65
+ if (!selection.plugin) throw new Error('所选语音识别插件尚未安装');
66
+ const { plugin } = selection;
67
+ let settings;
68
+ let transcript;
69
+ try {
70
+ settings = normalizeSpeechSettings(plugin, selection.settings, { required: true });
71
+ const boundedSignal = AbortSignal.any([AbortSignal.timeout(120000), ...(signal ? [signal] : [])]);
72
+ transcript = await invoke(plugin, { wav, settings, signal: boundedSignal, config });
73
+ } catch (error) {
74
+ signal?.throwIfAborted();
75
+ if (error?.name === 'TimeoutError') throw new Error('语音识别超时,请重试');
76
+ throw new Error(redactSpeechError(error, plugin, settings || selection.settings));
77
+ }
78
+ signal?.throwIfAborted();
79
+ if (typeof transcript !== 'string') throw new Error('语音识别插件必须返回文字');
80
+ transcript = transcript.trim();
81
+ if (transcript.length < 3 || transcript.length > 2000) {
82
+ throw new Error(transcript ? '识别文字过长或过短,请重试' : '语音识别尚未配置或未听清内容');
83
+ }
84
+ return transcript;
85
+ }