benchgo-plugin 0.0.0-stage → 0.0.3

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 (180) hide show
  1. package/DEEPSEEK-LICENSE +21 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +17 -0
  4. package/PASEO-LICENSE +211 -0
  5. package/README.md +256 -2
  6. package/TOOL-CONFIG.md +33 -0
  7. package/VERIFICATION.md +297 -0
  8. package/assets/fonts/README.md +13 -0
  9. package/assets/fonts/manifest.json +437 -0
  10. package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
  11. package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
  12. package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
  13. package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
  14. package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
  15. package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
  16. package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
  17. package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
  18. package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
  19. package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
  20. package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
  21. package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
  22. package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
  23. package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
  24. package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
  25. package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
  26. package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
  27. package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
  28. package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
  29. package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
  30. package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
  31. package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
  32. package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
  33. package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
  34. package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
  35. package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
  36. package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
  37. package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
  38. package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
  39. package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
  40. package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
  41. package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
  42. package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
  43. package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
  44. package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
  45. package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
  46. package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
  47. package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
  48. package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
  49. package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
  50. package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
  51. package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
  52. package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
  53. package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
  54. package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
  55. package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
  56. package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
  57. package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
  58. package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
  59. package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
  60. package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
  61. package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
  62. package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
  63. package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
  64. package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
  65. package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
  66. package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
  67. package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
  68. package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
  69. package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
  70. package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
  71. package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
  72. package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
  73. package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
  74. package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
  75. package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
  76. package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
  77. package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
  78. package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
  79. package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
  80. package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
  81. package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
  82. package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
  83. package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
  84. package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
  85. package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
  86. package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
  87. package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
  88. package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
  89. package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
  90. package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
  91. package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
  92. package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
  93. package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
  94. package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
  95. package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
  96. package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
  97. package/client/assets/iconsax/LICENSE +16 -0
  98. package/client/assets/iconsax/SOURCE.md +14 -0
  99. package/config/routergo-deepseek.example.yml +25 -0
  100. package/config.example.json +24 -0
  101. package/cordis.patch.yml +5 -0
  102. package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
  103. package/lib/client.js +3577 -0
  104. package/lib/link-web/app.css +92 -0
  105. package/lib/link-web/app.js +599 -0
  106. package/lib/link-web/index.html +1 -0
  107. package/lib/link-web/workbench.js +118 -0
  108. package/lib/link.mjs +244 -0
  109. package/lib/relay/ip-certificate.mjs +559 -0
  110. package/lib/relay/relay.example.json +8 -0
  111. package/lib/relay/run-relay-server.cjs +4100 -0
  112. package/lib/server/bootstrap.mjs +147 -0
  113. package/lib/server/bootstrap.sh +39 -0
  114. package/lib/server/manifest.json +8 -0
  115. package/lib/server/server-seed.tar.gz +0 -0
  116. package/locale/en.json +6 -0
  117. package/locale/zh-CN.json +6 -0
  118. package/openapi.json +629 -0
  119. package/package.json +104 -4
  120. package/shared/plugin-install.mjs +198 -0
  121. package/shared/tool-quick-settings.mjs +70 -0
  122. package/shared/update-feed.mjs +191 -0
  123. package/shared/update-public-key.pem +3 -0
  124. package/src/cli.mjs +102 -0
  125. package/src/commands.mjs +17 -0
  126. package/src/delegation.mjs +47 -0
  127. package/src/fonts.mjs +17 -0
  128. package/src/harness-adapter.mjs +228 -0
  129. package/src/harness-commands.mjs +39 -0
  130. package/src/harness-selection.mjs +5 -0
  131. package/src/harness-usage.mjs +31 -0
  132. package/src/isolated-launch.mjs +22 -0
  133. package/src/line-process.mjs +114 -0
  134. package/src/link-plugin.mjs +21 -0
  135. package/src/mcp.mjs +21 -0
  136. package/src/model-providers.mjs +125 -0
  137. package/src/models.mjs +37 -0
  138. package/src/native-auth.mjs +32 -0
  139. package/src/picker-catalog.mjs +82 -0
  140. package/src/plugin.mjs +138 -0
  141. package/src/projects.mjs +96 -0
  142. package/src/provider-access.mjs +11 -0
  143. package/src/providers/acp.mjs +138 -0
  144. package/src/providers/claude.mjs +115 -0
  145. package/src/providers/codex.mjs +134 -0
  146. package/src/providers/opencode.mjs +138 -0
  147. package/src/providers/pi-permissions.ts +24 -0
  148. package/src/providers/pi.mjs +97 -0
  149. package/src/questions.mjs +34 -0
  150. package/src/recent-workspace.mjs +36 -0
  151. package/src/runtime.mjs +421 -0
  152. package/src/session-routes.mjs +89 -0
  153. package/src/store.mjs +98 -0
  154. package/src/tool-catalog.mjs +22 -0
  155. package/src/tool-config.mjs +94 -0
  156. package/src/tool-install.mjs +31 -0
  157. package/src/tool-profiles.mjs +114 -0
  158. package/src/tooling.mjs +90 -0
  159. package/src/updates.mjs +156 -0
  160. package/src/usage-import-parsers.mjs +37 -0
  161. package/src/usage-import.mjs +114 -0
  162. package/src/usage-timeline.mjs +36 -0
  163. package/src/usage.mjs +142 -0
  164. package/src/web.mjs +274 -0
  165. package/voice/README.md +49 -0
  166. package/voice/STREAMING.md +141 -0
  167. package/voice/VERIFICATION.md +36 -0
  168. package/voice/client/audio-worklet.js +35 -0
  169. package/voice/locale/en.json +1 -0
  170. package/voice/locale/zh-CN.json +1 -0
  171. package/voice/src/asr.mjs +44 -0
  172. package/voice/src/calls.mjs +37 -0
  173. package/voice/src/config.mjs +139 -0
  174. package/voice/src/media.mjs +70 -0
  175. package/voice/src/plugin.mjs +10 -0
  176. package/voice/src/realtime-protocols.mjs +30 -0
  177. package/voice/src/realtime-socket.mjs +86 -0
  178. package/voice/src/realtime.mjs +130 -0
  179. package/voice/src/tts.mjs +16 -0
  180. package/voice/src/web.mjs +171 -0
package/src/web.mjs ADDED
@@ -0,0 +1,274 @@
1
+ import { harnessSelection } from './harness-selection.mjs';
2
+ import { realpath, readFile, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { discoverModels } from './models.mjs';
6
+ import { DelegationBridge } from './delegation.mjs';
7
+ import { projectActions } from './projects.mjs';
8
+ import { recentWorkspaceActions } from './recent-workspace.mjs';
9
+ import { mountFonts } from './fonts.mjs';
10
+
11
+ const webOwner = id => `commander-web:${id}`;
12
+ const text = (value, name, max = 20000) => {
13
+ if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`Invalid ${name}`);
14
+ return value;
15
+ };
16
+
17
+ const unavailableResponse = error => Response.json({
18
+ error: `BenchGo 启动失败:${error.message}`,
19
+ code: 'COMMANDER_UNAVAILABLE',
20
+ }, { status: 503, headers: { 'cache-control': 'no-store' } });
21
+
22
+ /** Keep startup failures visible on the same owner-authenticated API. */
23
+ export function mountUnavailableWeb(ctx, error) {
24
+ ctx.effect(() => ctx.connection.fetch.register({
25
+ path: '/api/commander', methods: ['POST'], requestBody: 'buffered',
26
+ fetch: async () => unavailableResponse(error),
27
+ }));
28
+ }
29
+
30
+ /** Mount direct-engine controls on the existing authenticated owner-only Connection carrier. */
31
+ export async function mountWeb(ctx, commander, config = {}, dependencies = {}) {
32
+ let dispatch, failure;
33
+ const initialized = Promise.resolve().then(async () => {
34
+ try { dispatch = await initializeWeb(ctx, commander, config, dependencies); }
35
+ catch (error) { failure = error; ctx.logger?.error?.(`BenchGo sidebar startup failed: ${error.message}`); }
36
+ });
37
+ ctx.effect(() => ctx.connection.fetch.register({
38
+ path: '/api/commander', methods: ['POST'], requestBody: 'buffered',
39
+ async fetch(request) {
40
+ await initialized;
41
+ if (failure) return unavailableResponse(failure);
42
+ try { return Response.json(await dispatch(await request.json()), { headers: { 'cache-control': 'no-store' } }); }
43
+ catch (error) { return Response.json({ error: error.message, ...(error.session ? { session: error.session } : {}) }, { status: 400, headers: { 'cache-control': 'no-store' } }); }
44
+ },
45
+ }));
46
+ await initialized;
47
+ return input => { if (failure) throw failure; return dispatch(input); };
48
+ }
49
+
50
+ async function initializeWeb(ctx, commander, config, dependencies) {
51
+ const maxDepth = config.maxDelegationDepth ?? 3;
52
+ const maxChildren = config.maxDelegationChildren ?? 12;
53
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 1 || maxDepth > 8) throw new Error('maxDelegationDepth must be an integer from 1 to 8');
54
+ if (!Number.isSafeInteger(maxChildren) || maxChildren < 1 || maxChildren > 64) throw new Error('maxDelegationChildren must be an integer from 1 to 64');
55
+ const models = new Map();
56
+ const starts = new Map();
57
+ const projects = projectActions(ctx);
58
+ const recent = recentWorkspaceActions(ctx, commander.store.directory);
59
+ const dismissedPath = join(commander.store.directory, 'dismissed-harness.json');
60
+ let deletedHarnessIds = [];
61
+ try {
62
+ const saved = JSON.parse(await readFile(dismissedPath, 'utf8'));
63
+ if (!Array.isArray(saved) || saved.some(id => typeof id !== 'string' || !id || id.length > 200)) throw new Error('Invalid dismissed Harness session list');
64
+ deletedHarnessIds = [...new Set(saved)];
65
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
66
+ let dismissWrites = Promise.resolve();
67
+ ctx.effect(() => () => dismissWrites);
68
+ const capabilities = { delegation: { available: false }, fonts: { available: false } };
69
+ let delegationError;
70
+ const unavailable = (name, error) => {
71
+ capabilities[name] = { available: false, error: error.message, ...(error.code ? { code: error.code } : {}) };
72
+ ctx.logger?.warn?.(`BenchGo ${name} unavailable: ${error.message}`);
73
+ };
74
+ const project = id => {
75
+ const p = ctx.workspaceRegistry.get(text(id, 'projectId', 200));
76
+ if (!p) throw new Error('Project no longer exists');
77
+ return p;
78
+ };
79
+ const owned = id => {
80
+ const s = commander.snapshot(text(id, 'sessionId', 200));
81
+ if (!s.ownerSessionId?.startsWith('commander-web:')) throw new Error('This session belongs to a Harness conversation; control it there');
82
+ return s;
83
+ };
84
+ const state = () => ({
85
+ projects: ctx.workspaceRegistry.list().map(p => ({ id: p.id, path: p.path, title: p.title, sessionIds: p.sessionIds })),
86
+ sessions: commander.list(),
87
+ deletedHarnessIds: [...deletedHarnessIds],
88
+ capabilities,
89
+ });
90
+ const result = id => {
91
+ const s = commander.snapshot(id);
92
+ const events = commander.get(id).events.filter(e => e.turnId === s.turnId);
93
+ return { session: s, text: events.filter(e => e.type === 'text.delta').map(e => e.data.text).join('').slice(-60000),
94
+ events: events.filter(e => ['turn.completed', 'session.error', 'provider.error'].includes(e.type)).slice(-10), cursor: s.cursor,
95
+ pending: !['idle', 'failed', 'disconnected', 'closed'].includes(s.status),
96
+ ...(s.status === 'running' || s.status === 'waiting' ? { next: 'Read again with operation=result and sessionId; do not create a duplicate child.' } : {}),
97
+ };
98
+ };
99
+ commander.delegate = async (parentId, args) => {
100
+ if (delegationError) throw new Error(`Native delegation unavailable: ${delegationError.message}`, { cause: delegationError });
101
+ const parent = commander.snapshot(parentId);
102
+ if (!parent.ownerSessionId) throw new Error('Native delegation requires an owning conversation');
103
+ if (!args || typeof args !== 'object') throw new Error('Invalid delegation arguments');
104
+ if (args.operation === 'result') {
105
+ const child = commander.snapshot(text(args.sessionId, 'sessionId', 200));
106
+ if (child.parentId !== parentId) throw new Error('Only direct delegated children can be read');
107
+ return result(child.id);
108
+ }
109
+ const provider = text(args.provider, 'provider', 100);
110
+ const prompt = text(args.text, 'text');
111
+ let depth = 0, ancestor = parent;
112
+ while (ancestor.parentId) { ancestor = commander.snapshot(ancestor.parentId); depth++; }
113
+ if (depth >= maxDepth) throw new Error('Delegation depth limit reached');
114
+ if (commander.list().filter(s => s.parentId === parentId && !s.archived).length >= maxChildren) throw new Error('Delegation child limit reached');
115
+ const answer = await commander.ask(parentId, { kind: 'permission', method: 'commander_delegate', details: { provider, model: args.model, text: prompt, cwd: parent.cwd } });
116
+ if (!answer.allow) throw new Error('Delegation denied by user');
117
+ if (!commander.drivers.has(parentId)) throw new Error('Parent session was disconnected');
118
+ // Re-resolve the registered project: native engines cannot choose another directory.
119
+ const p = parent.projectId ? project(parent.projectId) : ctx.workspaceRegistry.list().find(p => p.path === parent.cwd);
120
+ if (!p) throw new Error('Parent project is no longer registered');
121
+ const child = await commander.create({ provider, model: args.model, cwd: p.path, projectId: p.id, parentId, ownerSessionId: parent.ownerSessionId, title: prompt.slice(0, 60) });
122
+ commander.event(parentId, 'delegation.created', { sessionId: child.id, provider, model: args.model ?? null });
123
+ await commander.submit(child.id, prompt);
124
+ await commander.wait(child.id, { timeout: 55000 });
125
+ const value = result(child.id);
126
+ commander.event(parentId, 'delegation.result', { sessionId: child.id, status: value.session.status });
127
+ return value;
128
+ };
129
+ const dispatch = async input => {
130
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Expected an object');
131
+ const { action } = input;
132
+ if (action === 'state') return state();
133
+ if (action === 'ensureRecent') return recent.ensure(input);
134
+ if (action === 'binding') {
135
+ const ownerSessionId = text(input.harnessSessionId, 'harnessSessionId', 200);
136
+ const session = commander.list().find(item => !item.archived && (!item.parentId || item.harnessAdopted) && item.ownerSessionId === ownerSessionId);
137
+ return { bound: !!session, ...(session ? { id: session.id, cursor: session.cursor, provider: session.provider, model: session.model ?? 'native-default', route: session.route ? { providerId: session.route.providerId, model: session.route.model } : null } : {}) };
138
+ }
139
+ if (action === 'browse') return projects.browse(input);
140
+ if (action === 'clone') return projects.clone(input);
141
+ if (action === 'dismiss') {
142
+ const id = text(input.harnessSessionId, 'harnessSessionId', 200);
143
+ const task = dismissWrites.then(async () => {
144
+ if (deletedHarnessIds.includes(id)) return { deletedHarnessIds: [...deletedHarnessIds] };
145
+ if (deletedHarnessIds.length >= 10000) throw new Error('Dismissed session limit reached');
146
+ const next = [...deletedHarnessIds, id];
147
+ const temp = `${dismissedPath}.${randomUUID()}.tmp`;
148
+ try { await writeFile(temp, JSON.stringify(next), { mode: 0o600 }); await rename(temp, dismissedPath); }
149
+ finally { await rm(temp, { force: true }); }
150
+ deletedHarnessIds = next;
151
+ return { deletedHarnessIds: [...next] };
152
+ });
153
+ dismissWrites = task.catch(() => {});
154
+ return task;
155
+ }
156
+ if (action === 'doctor') return { providers: await commander.doctor() };
157
+ if (action === 'project') {
158
+ const path = text(input.path, 'path', 4096);
159
+ if (!path.startsWith('/')) throw new Error('Project path must be absolute');
160
+ const p = await ctx.workspaceRegistry.create(await realpath(path));
161
+ return { project: { id: p.id, path: p.path, title: p.title } };
162
+ }
163
+ if (action === 'models') {
164
+ const p = project(input.projectId);
165
+ const provider = text(input.provider, 'provider', 100);
166
+ if (!commander.providers.has(provider)) throw new Error('Unknown provider');
167
+ const key = `${provider}:${p.path}`;
168
+ let promise = models.get(key);
169
+ if (!promise) { promise = discoverModels(provider, await commander.providerConfig(provider), p.path); models.set(key, promise); promise.catch(() => models.delete(key)); }
170
+ return { models: await promise };
171
+ }
172
+ if (action === 'create') {
173
+ const p = project(input.projectId);
174
+ const provider = text(input.provider, 'provider', 100);
175
+ if (input.model !== undefined && typeof input.model !== 'string') throw new Error('Invalid model');
176
+ return { session: await commander.create({ provider, cwd: p.path, projectId: p.id, model: input.model, title: input.title, ownerSessionId: webOwner(p.id) }) };
177
+ }
178
+ if (action === 'start') {
179
+ // Validate before creating anything. The durable owner identifies this
180
+ // first message even if connecting fails or the HTTP response is lost.
181
+ const prompt = text(input.text, 'text');
182
+ const requestId = text(input.requestId, 'requestId', 200);
183
+ const p = project(input.projectId);
184
+ const provider = text(input.provider, 'provider', 100);
185
+ if (!commander.providers.has(provider)) throw new Error('Unknown provider');
186
+ if (input.model !== undefined && typeof input.model !== 'string') throw new Error('Invalid model');
187
+ const prefix = `commander-web:start:${encodeURIComponent(requestId)}:`;
188
+ const fingerprint = createHash('sha256').update(JSON.stringify([p.id, provider, input.model || null, prompt])).digest('hex');
189
+ const ownerSessionId = `${prefix}${fingerprint}`;
190
+ const inFlight = starts.get(requestId);
191
+ if (inFlight) {
192
+ if (inFlight.ownerSessionId !== ownerSessionId) throw new Error('requestId was already used for a different first message');
193
+ return inFlight.promise;
194
+ }
195
+ const existing = commander.list().find(s => s.ownerSessionId?.startsWith(prefix));
196
+ if (existing && existing.ownerSessionId !== ownerSessionId) throw new Error('requestId was already used for a different first message');
197
+ const promise = (async () => {
198
+ let session = existing;
199
+ try {
200
+ if (!session) session = await commander.create({ provider, cwd: p.path, projectId: p.id, model: input.model, title: prompt.slice(0, 60), ownerSessionId });
201
+ // A durable receipt means the native process may already have seen
202
+ // this message. Ask submit for its receipt without reconnecting or
203
+ // blindly executing the prompt again, including after a restart.
204
+ if (!Object.hasOwn(commander.get(session.id).receipts, requestId) && !commander.drivers.has(session.id)) await commander.connect(session.id);
205
+ await commander.submit(session.id, prompt, { requestId });
206
+ return { session: commander.snapshot(session.id) };
207
+ } catch (error) {
208
+ session ??= commander.list().find(s => s.ownerSessionId === ownerSessionId);
209
+ if (session) error.session = commander.snapshot(session.id);
210
+ throw error;
211
+ }
212
+ })();
213
+ starts.set(requestId, { ownerSessionId, promise });
214
+ try { return await promise; } finally { starts.delete(requestId); }
215
+ }
216
+ if (action === 'events') {
217
+ // Owner-authenticated human may inspect conversation-owned delegations as well.
218
+ const id = text(input.id, 'sessionId', 200);
219
+ return { session: commander.snapshot(id), ...commander.events(id, input.after ?? 0, 500) };
220
+ }
221
+ if (action === 'adopt') {
222
+ const s = commander.snapshot(text(input.id, 'sessionId', 200));
223
+ if (!s.ownerSessionId?.startsWith('commander-web:') && !s.parentId) throw new Error('Only legacy or delegated sessions can be adopted');
224
+ if (['running', 'waiting', 'connecting'].includes(s.status)) throw new Error('Finish or interrupt the active native session before opening it in Harness');
225
+ const harnessId = text(input.harnessSessionId, 'harnessSessionId', 200);
226
+ const agent = ctx.get('agents')?.get(harnessId);
227
+ if (!agent || agent.session.header.cwd !== s.cwd) throw new Error('Harness session must be open in the same project');
228
+ const selection = harnessSelection(ctx, agent);
229
+ if (selection.provider !== `commander-${s.provider}` || selection.model !== (s.model || 'native-default')) throw new Error('Harness session must select the same native tool and model');
230
+ const record = commander.get(s.id);
231
+ record.ownerSessionId = harnessId;
232
+ record.harnessAdopted = true;
233
+ await commander.persist();
234
+ return { session: commander.snapshot(s.id) };
235
+ }
236
+ // The Connection route is owner-authenticated. Humans may manage native
237
+ // records owned by Harness too; model-side tools retain their own scope.
238
+ if (action === 'archive') {
239
+ const id = text(input.id, 'sessionId', 200);
240
+ if (input.archived !== undefined && typeof input.archived !== 'boolean') throw new Error('Invalid archived flag');
241
+ return commander.archive(id, input.archived ?? true);
242
+ }
243
+ if (action === 'delete') return commander.remove(text(input.id, 'sessionId', 200));
244
+ const s = owned(input.id);
245
+ if (action === 'send') {
246
+ const prompt = text(input.text, 'text');
247
+ if (['disconnected', 'closed'].includes(s.status)) await commander.connect(s.id);
248
+ if (!s.title) { commander.get(s.id).title = prompt.slice(0, 60); await commander.persist(); }
249
+ return commander.submit(s.id, prompt, { requestId: text(input.requestId, 'requestId', 200) });
250
+ }
251
+ if (action === 'connect') return commander.connect(s.id);
252
+ if (action === 'interrupt') { await commander.interrupt(s.id); return commander.snapshot(s.id); }
253
+ if (action === 'steer') { await commander.steer(s.id, text(input.text, 'text')); return commander.snapshot(s.id); }
254
+ if (action === 'close') return commander.disconnect(s.id);
255
+ if (action === 'history') return { history: await commander.history(s.id) };
256
+ if (action === 'approve') { commander.approve(s.id, text(input.approvalId, 'approvalId', 200), input.answer); return commander.snapshot(s.id); }
257
+ throw new Error(`Unknown BenchGo action: ${action}`);
258
+ };
259
+ // Optional localhost MCP and fonts must not remove the sidebar's core API.
260
+ try {
261
+ const bridge = await (dependencies.openDelegationBridge ?? (c => new DelegationBridge(c).open()))(commander);
262
+ commander.delegationBridge = bridge;
263
+ ctx.effect(() => async () => {
264
+ if (commander.delegationBridge === bridge) commander.delegationBridge = undefined;
265
+ await bridge.close();
266
+ });
267
+ capabilities.delegation = { available: true };
268
+ } catch (error) { delegationError = error; unavailable('delegation', error); }
269
+ try {
270
+ await (dependencies.mountFonts ?? mountFonts)(ctx);
271
+ capabilities.fonts = { available: true };
272
+ } catch (error) { unavailable('fonts', error); }
273
+ return dispatch;
274
+ }
@@ -0,0 +1,49 @@
1
+ # BenchGo 语音
2
+
3
+ 协议配置、使用方式与验证范围见 [STREAMING.md](STREAMING.md)。默认采用 OpenAI 兼容接口;供应商特有协议需要明确选择。
4
+
5
+ BenchGo 内置语音模块,目录 `plugins/commander/voice/`,技术包名 `benchgo`。不修改官方引擎,复用官方输入框、消息流、模型选择、Agent 与人工审批。
6
+
7
+ ## 使用
8
+
9
+ 在「设置 → 语音」中分别启用语音转文本、朗读回复、实时通话,填写 ASR/TTS 的 API 地址、模型、密钥和 TTS 音色,按服务支持范围选择协议后保存。原生实时识别还可填写完整 WSS 地址。三项默认关闭;新配置中的 Step 2.5 模型 ID 仅是当前预填偏好,不预设或绑定供应商,也不代表该模型能在任意服务上使用。
10
+
11
+ - **语音转文本**:输入工具栏的麦克风开始/停止;普通 HTTP/SSE 每约 1.5 秒上传一个 WAV 片段,按顺序实时更新可编辑草稿。不会自动发送。用户编辑导致草稿 revision 变化时保留转录提示,不覆盖用户输入。
12
+ - **朗读回复**:点击独立扬声器图标切换,悬停显示开启/关闭气泡。开启后跟随新生成的当前对话输出,按句预取合成、按顺序流式播放;最终消息按 turn/step 关联流式 attempt,只补读尚未推送的后缀,不重读已推送正文或历史。
13
+ - **实时通话**:点击「通话」,停说并识别完成后提交给当前对话的原有 Agent。仅发送真实转录文字,不追加内部播放状态。用户再次开口立即清空本地播放、取消当前生成,隔离迟到结果;工具权限仍须在原有屏幕界面确认,语音不自动批准。点击「挂断」释放采集、识别、合成、播放与通话租约。
14
+
15
+ 浏览器需要 HTTPS 或 localhost,以及麦克风授权;远程服务部署时使用已配置的 HTTPS 域名。所有请求走现有 Harness owner 鉴权,无额外中继。ASR 接收音频,TTS 接收经过过滤的回复文本,当前 Agent 接收转录文本,实际数据出口取决于你填写的服务地址。
16
+
17
+ ## 模型接口
18
+
19
+ ASR 默认采用 OpenAI 兼容 `POST <baseURL>/audio/transcriptions`:multipart 字段包含 `file`(24 kHz 单声道 PCM16 WAV)、`model`、`response_format: json`。`transcriptions` 接收 JSON `text`;`transcriptions-sse` 额外发送 `stream=true`,接收转录增量。SSE 是分段上传后的流式输出,不是持续音频输入。
20
+
21
+ 持续 PCM 输入显式选择 `openai-websocket` 或 `step-websocket`:前者使用 OpenAI session/delta 协议,默认 `<baseURL>/realtime?intent=transcription`;后者是独立的 Step wire adapter,默认 `<baseURL>/realtime/asr/stream`。两者共用 20 ms PCM 采集、独立二进制 WebSocket、有界发送队列、服务端 WSS 和取消机制;不会根据域名或供应商 ID 改写路由。`asr.realtime.url` 可配置完整 HTTP(S)/WS(S) 地址及非敏感查询参数,`asr.realtime.query` 支持非敏感路由字段与 `{model}` 占位符,详见 [STREAMING.md](STREAMING.md)。切换供应商会清理旧实时路由覆盖;显式 `protocol` 优先,只有无协议字段的明确旧模型配置保留兼容推断。
22
+
23
+ TTS 默认 `speech` 使用 OpenAI 兼容 `POST <baseURL>/audio/speech`,只发送 `model`、`voice`、`input`、`response_format: pcm`。明确选择 `step-speech` 才额外发送 `sample_rate: 24000`、`stream: true`;无协议字段的明确 Step 旧模型保留兼容推断,显式 `speech` 可覆盖。播放合同统一为 **PCM16LE / 24000 Hz / 单声道**,并拒绝冲突的采样率或声道元数据;MP3、WAV、其他采样率不在当前适配范围。并非任意文字模型 API 都支持语音接口。
24
+
25
+ API Key 仅存服务端,通过上游 `Authorization` 请求头传递,禁止放入实时 URL 或查询参数。配置文件权限 0600,目录权限 0700。前端只获得已配置标记和掩码;留空密钥保留原值,明确点击清除才删除。存储为 `$DSH_HOME/voice/config.json`,插件配置 `dataDir` 可覆盖。
26
+
27
+ ## 安装与构建
28
+
29
+ ```sh
30
+ npm --prefix plugins/commander install --ignore-scripts
31
+ npm run build:voice
32
+ npm run test:voice
33
+ ```
34
+
35
+ 向官方 profile 的 `package.json` 添加 `dependencies: {"benchgo":"link:/absolute/path/plugins/commander"}`,并在 `dsh.profile.bundles` 数组加入 `benchgo`,在该 profile 安装依赖后由官方 `dsh web` / Desktop 启动。当前服务器 Web profile 已接入;其他 profile 需自行安装。不改上游工作区、源码或锁文件。
36
+
37
+ ## 机制与边界
38
+
39
+ 复用历史实时语音处理层的生命周期、取消隔离和播放确认思路。本文描述本插件实际实现,不能把原项目的全部能力视为已经迁移。
40
+
41
+ - AudioWorklet 连续采集和播放,保留重采样相位;24 kHz PCM16LE。实际消费完整片段后确认播放,收到数据不等于听到。
42
+ - 浏览器 AEC / NS / AGC,自适应 RMS VAD、预卷和停说判定。已使用持续 windowed-sinc 重采样与自适应起播缓冲;**未移植 TEN VAD、RNNoise**,外放建议先用耳机验证。
43
+ - 实时上行待发送队列最多 600 ms,拥塞淘汰旧帧并提示,避免延迟累积;不会因此直接挂断。播放 60–180 ms 自适应起播、短暂欠载不解除回声保护,插话确认 200 ms、停说 320 ms(实现值,非端到端性能保证)。
44
+ - 文本可朗读过滤、首句/首分句及时合成、3 路 TTS 预取、有序播放、有限缓冲;过滤不等于完备敏感数据防护。
45
+ - 单会话单主动设备,45 秒租约,15 秒心跳,最长 30 分钟;进程重启不恢复旧音频、不重放工具。
46
+ - 挂断、断网、切换会话、插件卸载和配置更新传播取消;取消不会撤销已经完成的工具操作。
47
+ - 播放确认仅用于本地片段队列与播放状态,不发送给 Agent,也不追加到用户消息。官方 `conversation.send(text)` 将文本原样提交为用户消息,目前没有接入可按轮安全传递播放事实的私有上下文接口;不承诺模型知道用户实际听到的位置。发送失败仅恢复待发转录,切换会话丢弃旧会话待发内容与迟到回调。
48
+
49
+ 验证包含本地模拟协议与 WebSocket、取消、租约、音频采样/队列、输出过滤和客户端 Agent 事件桥接。历史 RouterGo HTTP/SSE ASR 与 Step TTS 已使用合成短音真实探测;原生 WSS 仅确认 101 握手,尚无真实音频闭环。新增 OpenAI WSS adapter 仅经本地 mock 验证,未用真实账号联调;真实手机麦克风、外放回声和弱网长通话未验证。具体记录见 STREAMING.md。
@@ -0,0 +1,141 @@
1
+ # 流式语音协议与验证
2
+
3
+ 当前说明更新于 2026-10-20;历史探测日期另行标注。语音实现位于 `plugins/commander/voice/`,不修改上游和 reference。
4
+
5
+ 设置 → 语音 → 识别协议显式选择。默认采用 OpenAI 兼容接口;协议与路由不根据供应商域名或 ID 判断。Step 模型只是当前预填偏好,不代表绑定供应商。显式 `protocol` 始终优先;无协议字段的明确旧 MiMo/Step 模型保留兼容推断。
6
+
7
+ | 协议 | 默认上游接口 | 能力 |
8
+ | --- | --- | --- |
9
+ | `transcriptions` | `/audio/transcriptions` | WAV 分段上传,JSON `text` |
10
+ | `transcriptions-sse`(新配置默认) | `/audio/transcriptions`,multipart + `stream=true` | 分段上传后增量输出 |
11
+ | `openai-websocket` | `/realtime?intent=transcription` | OpenAI session/delta 协议,持续 PCM 输入 |
12
+ | `step-websocket` | `/realtime/asr/stream` | 独立 Step wire adapter,累计文本、修订、commit/final |
13
+ | `mimo-sse` | `/chat/completions`,`input_audio` + `stream` | 供应商扩展,片段上传后增量返回 |
14
+ | `step-sse`(旧接口) | `/audio/asr/sse`,`audio.data/input` | 供应商扩展,仅明确支持时使用 |
15
+
16
+ 原生 WSS 共用连接、鉴权、浏览器采集和事件转发机制,各协议 adapter 只处理上游 wire format。浏览器通过已鉴权 HTTP asr.start 建立供应商连接并领取一次性、10 秒有效的连接 ticket,再用独立 WebSocket /api/voice/realtime 持续发送 20 ms 二进制 PCM、接收识别与 ACK 事件;服务端保持上游 WSS,供应商密钥仅在服务端请求头中使用。Desktop 通过官方 connection.openWebSocket 选择 Host 与认证;Link 工作台和本地设备代理复用同一媒体协议,分别校验配对设备与私有代理会话。客户端 VAD 手动 commit,停说等待 final;挂断、断网、租约失效和配置变更关闭媒体流与连接。
17
+
18
+ `transcriptions-sse`、`mimo-sse` 与 `step-sse` 均为分段上传后的流式输出,不是双向连续音频。浏览器在约 1.5 秒音频或停说后形成 WAV 分段;SSE 最多 2 个分段请求并发,结果按分段顺序合并。模型必须支持所选接口;例如历史 Step 文件转录使用 `stepaudio-2.5-asr`,不能替换成仅支持 WSS 的 `-stream` 模型。Step 1.1 增量 delta 语义不在 Step adapter 范围。
19
+
20
+
21
+ ## 低延迟调度(2026-10-20 已实现)
22
+
23
+ - 保留供应商要求的 PCM16LE / 24 kHz / mono,采集帧为 480 样本(20 ms);不为照搬设备 Opus 参数增加编码/解码依赖。openai-websocket / step-websocket 实时路线逐帧上行,删除旧 100 ms 串行 HTTP 上传和实时 NDJSON 长请求,普通 HTTP/SSE 文件识别继续保留其受支持路径。不会静默改写模型或协议;设置页对分段通话提示实时路线选择。
24
+ - 待发送音频最多 30 帧 / 600 ms;拥塞淘汰最旧未发送帧,超过 600 ms 的帧过期。最多 12 帧在途并由 ACK 释放窗口,浏览器 socket 发送缓冲超过 1920 字节时暂停投递;服务端上游 socket 缓冲超过 12800 字节时非致命拒收当前帧,并提示用户重说。已进入 TCP 的帧不能撤回,以上均为调度限制,不是整轮延迟保证。
25
+ - 二进制帧头为三个 little-endian uint32:epoch / utteranceId / sequence,随后 1–480 个 PCM16 样本。文本控制为 attach / commit / clear,响应包括 ready / ack / partial / final / error / closed;ticket 在首条 attach 中提交,不放 URL。旧 HTTP asr.events / asr.commit / asr.clear 和 /api/voice/realtime/audio 已删除;asr.start / asr.end 保留建连与兜底释放。供应商协议 adapter 不变。
26
+ - commit 在调用时排入屏障;下一语段可立即采集入有界队列,但待旧 final 后才向不支持多段并行的上游发送。clear 优先清空队列、递增 epoch、取消旧结果,等供应商重连 ready 后恢复;重复静音清空重连期间新增的音频。
27
+ - 输入预卷最多 220 ms,普通起音确认 100 ms、播放期间插话确认 200 ms、停说 320 ms、播放结束回声保护 220 ms。沿用浏览器 AEC / NS / AGC 与自适应能量 VAD,未引入 TEN VAD 或 RNNoise;参数为实现值,外放误触发与轻声识别需真机验收。
28
+ - 播放从 60 ms 起播,欠载时每次增加 20 ms、最高 180 ms,稳定后回落;短尾在明确结束后直接播放。欠载不切回 listening,不解除 AEC 保护;明确结束/取消才清理活动态。保留实际消费 ACK、2 秒播放背压与三路有界 TTS 预取,不丢下行语音。
29
+ - 完整句和合格的首段自然逗号立即提交合成;已有 PCM 立即进入播放器,保留单批最多 40 ms,删除额外 20 ms 合包定时器。挂断不等待供应商无法完成的 reader cancel。
30
+
31
+ 部署:官方 Web/Desktop 复用现有 listener;Link 使用 /link/workbench/api/voice/realtime,本地设备代理追加既有 /s/TOKEN 前缀。plugins/link/config/nginx.example.conf 新增精确 WS 升级路由,保留既有服务器鉴权继承。现有官方 /api/ 反代也须透传 Upgrade/Connection;不新增公网端口,不绕过云端访问鉴权。修改反代配置后由部署方按实际域名、端口和证书路径加载,本次未改运行中的服务。
32
+
33
+ ## 实时路由配置
34
+
35
+ 设置页可选填完整实时 WSS 地址。不填写时按所选协议与 `baseURL` 构造默认路径;持久化的 `asr.realtime` 还支持非敏感查询配置:
36
+
37
+ ```json
38
+ {
39
+ "asr": {
40
+ "protocol": "openai-websocket",
41
+ "model": "your-transcription-model",
42
+ "realtime": {
43
+ "url": "wss://speech.example/v1/realtime?region=example",
44
+ "query": { "model": "{model}" }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ `url` 接受完整 `ws(s)` 或 `http(s)` 地址,HTTP(S) 会转换为 WS(S),可包含非敏感 query;`query` 叠加路由字段,支持 `{model}` 替换并安全编码。不能包含用户名、密码、片段、API Key、token 或授权查询字段;密钥仍走服务端 `Authorization`。供应商切换且未显式提供新的 `realtime` 时清理旧覆盖,避免沿用上一个服务的路径和查询。
51
+
52
+ 以下只是 RouterGo 的显式配置实例,不是默认路径,也不触发域名或供应商识别:
53
+
54
+ ```json
55
+ {
56
+ "asr": {
57
+ "protocol": "step-websocket",
58
+ "model": "stepaudio-2.5-asr-stream",
59
+ "realtime": {
60
+ "url": "wss://routergo.cn/api/v1/realtime",
61
+ "query": {
62
+ "model": "{model}",
63
+ "intent": "transcription",
64
+ "exit_id": "stepfun.audio.transcriptions.websocket"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## TTS 合同
72
+
73
+ 默认 `speech` 使用 `/audio/speech`,仅发送 `model`、`voice`、`input`、`response_format: pcm`;明确选择 `step-speech` 才额外发送 `sample_rate: 24000`、`stream: true`。无协议字段且模型明确属于旧 Step TTS 时兼容推断为 `step-speech`,显式 `speech` 始终覆盖推断。默认音色分别为 `alloy` / `cixingnansheng`,用户填写的音色优先。
74
+
75
+ 两种协议共用 PCM16LE、24 kHz、单声道播放合同;上游元数据冲突即拒绝。允许三段并发预取,响应禁止代理缓冲。接口路径兼容不保证所有模型、音色或媒体格式均可用,需按服务实际能力配置。
76
+
77
+ ## 当前输入与播放确认(2026-10-16)
78
+
79
+ 语音通话只把识别转录提交给官方 `conversation.send(text)`,不再追加“已完整听到 / 未确认完整听到”等播放状态注记。该接口没有逐轮私有上下文参数,目前也未接入其他可靠的私有播放上下文通道。AudioWorklet ACK、插话停止播放和取消生成仍在本地生效;模型不能据此判断用户听到的具体位置。发送失败仅恢复待发转录,挂断和切换会话清除旧输入,旧异步错误与播放器回调不影响新通话。此前追加播放事实到普通用户消息的行为已移除;历史会话消息不改写。
80
+
81
+ ## 验证
82
+
83
+ 历史记录(2026-10-16):当时语音测试 90/90 通过,覆盖 SSE partial/final、截断/取消、Step WSS update/append/commit/修订/final、当时的 RouterGo 路由、下行背压、鉴权、PCM 转发、配置失效、stop 等待 final 和草稿 revision 冲突;当时客户端构建通过,新增运行依赖 `ws@8.19.0`。这不是当前测试计数。
84
+
85
+ 本轮新增通用实时路由配置、OpenAI WSS adapter 和标准/Step TTS 协议隔离;OpenAI adapter 仅经本地 mock(含本机 WebSocket)验证,尚未进行真实供应商账号联调。真实 WSS 仅有下文 101 握手记录,不能视为音频事件闭环成功。本轮实际测试结果以交付记录为准,不沿用历史计数。
86
+
87
+ 通话 conversation without inject 已修复:从真实 Cordis 未注入 session scope 通过 get 获取 conversation,发送和取消回归通过。录音波形来自真实 PCM RMS;朗读与通话使用独立 Iconsax 图标,提供状态气泡和 reduced-motion。
88
+
89
+ ## 供应商实例与历史验证
90
+
91
+ 以下为特定供应商、模型和部署环境的历史事实,不定义通用协议或当前默认配置。
92
+
93
+ ### 早期 RouterGo 探测记录(已由下方正确路由复测更新)
94
+
95
+ 仅使用服务生成的短测试语音,无历史消息或用户录音。密钥仅服务器内读取,未输出到日志或文档。
96
+
97
+ | 请求 | 实际结果 |
98
+ | --- | --- |
99
+ | Step TTS stepaudio-2.5-tts / cixingnansheng / pcm / 24000 | 200,audio/pcm,103680 bytes,PCM16 mono24k 为 2.16 秒 |
100
+ | MiMo TTS mimo-v2.5-tts / alloy / pcm | 400,参数校验失败 |
101
+ | MiMo ASR mimo-v2.5-asr / chat/completions stream | 503 |
102
+ | Step /audio/asr/sse | 404;本次探测使用 stream 模型,非 SSE 合规模型 |
103
+ | WSS `/realtime/asr/stream` | 旧探测路径不适用于 RouterGo |
104
+ | RouterGo WSS `/api/v1/realtime?model=stepaudio-2.5-asr-stream&intent=transcription&exit_id=stepfun.audio.transcriptions.websocket` | 101 Switching Protocols;握手后保持连接,尚未完成真实音频会话验证 |
105
+
106
+ 协议实现和 mock 验证完成;RouterGo Step WSS 已完成 101 握手验证,真实音频事件闭环和延迟仍待账号联调。MiMo/Step SSE 已有真实接口验证。真实手机麦克风、外放回声、弱网长通话未验证。TEN VAD、RNNoise 尚未迁移;持续 sinc 重采样与自适应起播缓冲已在本轮实现。
107
+
108
+ 协议依据:Step 官方 api-reference/audio/asr-stream.md、asr-sse.md,以及 MiMo Speech Recognition API 文档。
109
+
110
+
111
+ ### 正确 RouterGo 路由复测与修复(2026-10-16)
112
+
113
+ 使用服务端现存供应商凭据,生成一句合成测试语音,不使用用户录音、不输出密钥,不修改官方引擎。
114
+
115
+ - MiMo `mimo-v2.5-asr`、Step `stepaudio-2.5-asr`:正确接口均为 `POST /audio/transcriptions`,multipart `file/model/response_format=json/stream=true`,`Accept: text/event-stream`。真实均 200,收到多个 `transcript.text.delta` 和最终 `transcript.text.done`;测试音正确转写。模型列表支持 ASR 不表示网关支持厂商私有路径。
116
+ - 2026-10-17 同一段 2.84 秒测试音复测:MiMo 首 partial/final 为 2253/2439 ms;Step `stepaudio-2.5-asr` 为 4365/4388 ms;Step `stepaudio-3-asr-max` 为 4845/4911 ms。此前对 RouterGo 旧路径的 404/503 探测已被纠正:正确 `/api/v1/realtime` 路由可完成 101 握手。
117
+ - RouterGo `stepaudio-2.5-asr-stream`(实时转写)和 `stepaudio-3-realtime-preview`(实时对话)均已确认可 101 升级;前者可通过 BenchGo 的 Step adapter 与显式实时路由配置接入,后者是完整双向对话协议,当前自有 Agent + ASR + TTS 架构暂不直接使用。
118
+ - 早期 `/chat/completions` MiMo 请求仍返回 503 `model_unavailable`,私有 Step `/audio/asr/sse` 使用正确非 stream 模型也为 404。因此新增明确的 `transcriptions-sse` 协议,不再将两个失败接口误判为 ASR 模型本身不可用。
119
+ - 插件真实处理器复测:Step TTS 200,41 个 PCM 包、122880 字节;合成音进入 MiMo ASR 后,NDJSON 返回 8 次 partial 和 final,文本为“你好,这是流式语音测试。”。单样本 TTS 首包约 3226 ms,ASR 首 partial 约 3171 ms、final 3422 ms;只是当次服务端时间,不是浏览器端整轮延迟或性能保证。
120
+ - ASR 属于持续采集、分段提交、响应增量的组合架构。RouterGo 原生双向 WSS 已完成握手验证,完整音频事件闭环仍待真实账号联调;不能将 SSE 描述为持续 PCM 双向识别。
121
+ - 历史迁移曾针对已知 RouterGo MiMo/Step 文件模型补齐 `transcriptions-sse`。当前迁移不判断 RouterGo 域名或供应商 ID,只对无协议字段的明确旧模型兼容推断;显式协议保留。迁移生效于启动内存快照,下次用户保存设置时持久化。
122
+ - 媒体 socket 的 `clear` 替换上游 socket,等待新 `session.updated`,保留浏览器 session id 与媒体连接,旧 socket epoch 事件全部丢弃,避免不支持 clear 的供应商污染新输入;原 HTTP 清空入口已删除。
123
+ - TTS 采样率/声道元数据若与 24 kHz mono 矛盾立即拒绝;保持 PCM 半样本跨包拼接和取消传播。错误仅向前端返回状态与有界机器码,不转发供应商原始错误消息。
124
+
125
+ ### `MALFORMED_RESPONSE` 根因
126
+
127
+ 线上默认 `deepseek-official` 适配器被配置到 RouterGo `/messages`。真实短请求返回 200 SSE,但只有 `message_delta` 和 `message_stop`,无 `message_start` 或内容块。官方引擎按合同拒绝并报 `event precedes message_start`;朗读只消费已有 Agent 输出,不制造这些事件。相同模型 RouterGo `/chat/completions` 返回正常 `chat.completion.chunk`。应使用共享供应商 `routergo` 的 `openai-completions` 路由,不伪造缺失事件,也不修改官方翻译器。
128
+
129
+
130
+ ### 历史部署路由修复
131
+
132
+ 当时该部署的默认路由已在 `.local/web/router.overlay.yml` 与 Web profile patch 持久改为 `routergo / deepseek-v4.1-flash`,修改前文件保存在 `.local/web/backups/voice-route-*`(目录 0700、文件 0600)。当前已加载、空闲且使用不兼容默认路由的失败会话通过官方 `session/selectModel` 切到同一模型的 RouterGo 路由,并用 `session/list` 复核;未编辑任何 Session append-only 日志,其他工具/模型选择未改变。服务重启由主部署流程执行。
133
+
134
+ ### 技术方案边界
135
+
136
+ 后端对应技术方案的职责包括:固定 24 kHz PCM16 mono 媒体合同、受限并发、流式转录/合成、取消传播、配置失效、原生 ASR 连接 epoch 隔离和清空、供应商路由实测。不把 SSE 音频文件识别标成原生双向识别,不把有限真实样本延迟当作性能保证。TEN VAD、RNNoise、Go 服务实现不在本后端移植范围;前端轮次/安全断句/连续播放/ACK 的具体实现以对应源码与验证记录为准。
137
+
138
+
139
+ ### 可朗读过滤实现记录
140
+
141
+ 跨增量保留行内代码内容;代码块、表格、裸 URL 分别每轮至多一次替代提示。Markdown 链接仅读标签,长未闭合链接/围栏仅保留有界状态。过滤短动作/舞台描述与表情,保留普通括号解释;仍使用同步有界处理进入现有三并发 PCM 队列,不等待网络或播放。断句保留完整句优先和安全 idle 补切。
@@ -0,0 +1,36 @@
1
+ # 首版验证记录
2
+
3
+ ## 2026-10-16 语音输入正文清理
4
+
5
+ - 官方 `IConversation.send(text)` 只接收文本并按用户 text block 原样提交;未接入可靠的逐轮私有播放上下文。已移除拼接到转录后的播放状态注记及其正文缓存,Agent 和消息列表只接收真实转录。旧会话已保存的历史消息保持原样。
6
+ - 保留 AudioWorklet 消费 ACK、本地有界片段队列、插话停止播放和取消生成;播放确认改为片段 ID 集合,不再缓存已听正文。发送失败恢复转录;切换会话屏蔽旧发送/取消失败和旧播放器回调。
7
+ - 新增纯转录、插话取消与 ACK、失败后重试、跨会话迟到结果回归;`npm run test:voice`:84/84 通过。未执行真实 ASR/TTS、视觉验收或图形启动,客户端构建由主流程统一执行。
8
+
9
+ ## 2026-10-16 自动朗读去重修复
10
+
11
+ - 根因:官方成功回复通过普通 `append` 发布最终 `assistant/message`,消息仅含 turn/step;流式正文却按 attemptId 缓存。原桥接只正确处理 `settle-assistant`,普通追加无法匹配已朗读前缀,导致整条回复再读一次。
12
+ - 修复:按 turn/step 关联流式 attempt,最终消息只补发未推送后缀;无正文的 attempt 退役也清理缓存。自动朗读和实时通话共用这一去重路径,不按正文内容全局去重,后续相同回复仍可正常播放。
13
+ - 验证:`npm run test:voice`,81/81 通过。新增回归覆盖成功追加、重复发布、流式基线、跨 step 相同正文,以及自动朗读开启后进入通话仍只有一条播放链。
14
+ - 未执行图形应用、视觉检查或真实 TTS 播放;客户端构建由本次集成统一执行。
15
+
16
+ 最新补充验证见 [STREAMING.md](STREAMING.md),下文为首版历史记录。
17
+
18
+ 日期:2026-10-16。只验证本插件及其公开接入点,未修改官方引擎与参考仓库。
19
+
20
+ - `npm run test:voice`:原独立语音测试 40/40 通过。覆盖配置持久化/密钥脱敏、模拟 ASR/TTS、流取消、同设备租约、采集重采样、实际消费 ACK、分段转写顺序、断句过滤、合成预取、Agent 增量去重、插话和启动/挂断竞态、审批保护。
21
+ - `npm --prefix plugins/commander run build:client`:通过。
22
+ - `npm run check`:官方 0.2.1-alpha.3,提交 571902028a75c64c1d767f4ae5fd099485f0ddf0,子模块干净;此命令不是功能验收。
23
+ - 当前 Web profile 已添加并安装 `benchgo`,服务正常;认证后的 `/api/voice` 返回默认全关闭、模型未配置的安全配置;匿名访问仍受官方鉴权保护。
24
+ - Chromium 交互检查:设置 → 语音页显示三个开关与模型配置,未捕获页面错误。
25
+ - Chromium 假麦克风 + 浏览器拦截的转录 fixture:输入栏麦克风启动、停止、AudioWorklet 加载及转录文字回填原生草稿成功,无页面错误。fixture 仅在该浏览器上下文生效,未保存测试供应商配置、未发真实模型请求、未创建聊天消息。
26
+
27
+ 未验证:真实供应商、真实麦克风/扬声器及移动设备,长通话外放回声、弱网和服务端进程退出的工具取消。持续 sinc 重采样、自适应起播缓冲已实现;尚未迁移 TEN VAD、RNNoise 和完整生产延迟指标。ASR 当前为分段实时 HTTP 转录;TTS 要求原始 PCM16LE 24k 单声道流。
28
+
29
+ ## 2026-10-20 低延迟媒体调度
30
+
31
+ - 二进制持续上行替代实时路径的逐包 HTTP/NDJSON;20 ms 采集、600 ms 有界待发、12 帧 ACK 窗口、拥塞丢旧/过期音频并反馈,保留供应商 PCM 协议与显式模型选择。
32
+ - Web/Desktop 复用官方 owner upgrade 鉴权;Link 工作台与设备本地代理增加配对认证通道,连接 ticket 一次性且 10 秒有效,撤销设备/租约/配置关闭媒体。部署样例增加精确 WS 升级路由。
33
+ - 停说/插话/起播参数、首分句流式合成、取消释放与旧路径删除见 STREAMING.md;没有引入 Opus、TEN VAD、RNNoise,也未将参考项目文档当作已实现能力。
34
+ - 本地真实 WebSocket fixture 验证二进制 PCM → 上游 append/commit → final、clear 重连 epoch、ticket 重放/错误租约拒绝、租约关闭;浏览器模拟验证延迟 ACK、队列淘汰/过期、跨语段屏障、拥塞警告、重复静音和未响应连接释放。未调用真实供应商、未启动图形应用,未验证真机麦克风、外放与弱网长通话。
35
+ - 最终自动回归:`npm run test:voice` 136/136;Link workbench/devices/transport 18/18。新增完整客户端 → 真实媒体 WebSocket → 模拟供应商 → final,以及真实 Cordis 兄弟 scope 的 Link 接入测试;Link 使用显式 `ctx.get('voice')` 获取可用服务,避免未注入属性在实际运行时抛错。挂断先于 start 响应的迟到租约也已验证释放。
36
+ - `npm --prefix plugins/commander run build:client` 通过,已同步客户端、Link 服务与服务器 seed;本次范围 `git diff --check` 通过。`npm run check` 确认官方子模块干净、入口一致,仅属结构检查。未修改或重载线上代理,未重启用户服务。
@@ -0,0 +1,35 @@
1
+ /* Persistent Blackman-windowed sinc conversion; packet edges retain phase/history. */
2
+ const RATE=24000, HALF=12;
3
+ function kernel(x,c){if(Math.abs(x)>=HALF)return 0;return (Math.abs(x)<1e-8?c:Math.sin(Math.PI*c*x)/(Math.PI*x))*(.42+.5*Math.cos(Math.PI*x/HALF)+.08*Math.cos(2*Math.PI*x/HALF));}
4
+ function coefficients(c){return Array.from({length:256},(_,phase)=>{const row=new Float32Array(HALF*2);let sum=0;for(let j=0;j<row.length;j++)sum+=(row[j]=kernel(phase/256+HALF-1-j,c));for(let j=0;j<row.length;j++)row[j]/=sum;return row;});}
5
+ class CaptureResampler {
6
+ constructor(rate){this.step=rate/RATE;this.cutoff=Math.min(1,RATE/rate)*.94;this.coefficients=coefficients(this.cutoff);this.ring=new Float32Array(4096);this.written=0;this.position=0;}
7
+ push(value,output){this.ring[this.written++%4096]=value;while(this.position+HALF<this.written){let sum=0;const integer=Math.floor(this.position),start=integer-HALF+1,row=this.coefficients[Math.min(255,Math.floor((this.position-integer)*256))];for(let j=0;j<HALF*2;j++){const index=start+j;sum+=(index<0?0:this.ring[index%4096])*row[j];}output(sum);this.position+=this.step;}}
8
+ }
9
+ class VoiceCaptureProcessor extends AudioWorkletProcessor {
10
+ constructor(){super();this.resampler=new CaptureResampler(sampleRate);this.frame=new Int16Array(480);this.used=0;this.muted=false;this.inputEpoch=0;this.port.onmessage=({data:m})=>{if(m.type==='mute'){this.muted=!!m.value;this.inputEpoch=m.epoch??this.inputEpoch;this.used=0;this.resampler=new CaptureResampler(sampleRate);}};}
11
+ process(inputs){const channels=inputs[0];if(this.muted||!channels?.length)return true;for(let i=0;i<channels[0].length;i++){let value=0;for(const c of channels)value+=c[i]||0;this.resampler.push(value/channels.length,v=>{this.frame[this.used++]=Math.round(Math.max(-1,Math.min(1,v))*32767);if(this.used===480){const data=this.frame.buffer;this.port.postMessage({type:'pcm',epoch:this.inputEpoch,data},[data]);this.frame=new Int16Array(480);this.used=0;}});}return true;}
12
+ }
13
+ class VoicePlaybackProcessor extends AudioWorkletProcessor {
14
+ constructor(){super();this.ring=new Float32Array(RATE*8);this.epoch=0;this.coefficients=coefficients(Math.min(1,sampleRate/RATE)*.94);this.reset();this.port.onmessage=({data:m})=>{if(m.type==='clear'){this.epoch=m.epoch;this.reset();}else if(m.epoch===this.epoch&&m.type==='pcm'){const samples=new Int16Array(m.data);if(samples.length+this.written-Math.floor(this.position)>this.ring.length-HALF){this.port.postMessage({type:'error',epoch:this.epoch,error:'播放缓冲超限'});return;}this.spans.push({at:this.written,id:m.id});for(const v of samples)this.ring[this.written++%this.ring.length]=v/32768;}else if(m.epoch===this.epoch&&m.type==='end')this.ends.push({at:this.written,id:m.id});};}
15
+ // Start after a short 60 ms cushion; grow it adaptively only when a stream
16
+ // underruns, so the first TTS packet becomes audible sooner.
17
+ reset(){this.ring.fill(0);this.position=0;this.written=0;this.acked=0;this.ends=[];this.spans=[];this.started=false;this.active=false;this.meterFrames=0;this.target=RATE*.06;this.stable=0;}
18
+ process(_inputs,outputs){const out=outputs[0]?.[0];if(!out)return true;out.fill(0);const step=RATE/sampleRate;let rendered=false;
19
+ for(let i=0;i<out.length;i++){
20
+ const remaining=this.written-this.position,complete=this.ends.some(e=>e.at===this.written);
21
+ if(!this.started){if(remaining>=this.target||complete&&remaining>0){this.started=true;if(!this.active){this.active=true;this.port.postMessage({type:'playing',id:this.spans[0]?.id,epoch:this.epoch});}}else continue;}
22
+ if(remaining<=0||!complete&&remaining<=HALF){if(this.started&&remaining<=HALF&&!complete){this.target=Math.min(RATE*.18,this.target+RATE*.02);this.port.postMessage({type:'buffering',epoch:this.epoch});}this.started=false;continue;}
23
+ let value=0;const integer=Math.floor(this.position),start=integer-HALF+1,row=this.coefficients[Math.min(255,Math.floor((this.position-integer)*256))];for(let j=0;j<HALF*2;j++){const at=start+j;value+=(at<0||at>=this.written?0:this.ring[at%this.ring.length])*row[j];}out[i]=value;this.position=Math.min(this.written,this.position+step);rendered=true;
24
+ }
25
+ // Confirm only audio consumed by this render quantum, never on receipt.
26
+ while(this.spans[1]?.at<=this.position)this.spans.shift();const consumed=Math.floor(this.position)-this.acked;if(consumed>0){this.acked+=consumed;this.port.postMessage({type:'consumed',count:consumed,epoch:this.epoch});}
27
+ let completed=false;while(this.ends[0]?.at<=this.position){this.port.postMessage({type:'played',id:this.ends.shift().id,epoch:this.epoch});completed=true;}
28
+ // An underrun is still an active utterance: keep echo/interrupt protection
29
+ // until its explicit end, including an end received after the last sample.
30
+ if(this.position>=this.written&&this.active&&completed){this.active=false;this.started=false;this.port.postMessage({type:'idle',epoch:this.epoch});}
31
+ if(rendered){this.meterFrames+=out.length;if(this.meterFrames>=sampleRate/15){this.meterFrames=0;let energy=0;for(const value of out)energy+=value*value;this.port.postMessage({type:'level',level:Math.min(1,Math.sqrt(energy/out.length)*4),epoch:this.epoch});}this.stable+=out.length/sampleRate;if(this.stable>=3){this.target=Math.max(RATE*.06,this.target-RATE*.02);this.stable=0;}}else this.stable=0;return true;
32
+ }
33
+ }
34
+ registerProcessor('voice-capture',VoiceCaptureProcessor);
35
+ registerProcessor('voice-playback',VoicePlaybackProcessor);
@@ -0,0 +1 @@
1
+ {"meta":{"title":"BenchGo","description":"Speech input, streamed read-aloud and Agent voice calls"}}
@@ -0,0 +1 @@
1
+ {"meta":{"title":"BenchGo","description":"语音转文本、流式朗读与 Agent 实时通话"}}
@@ -0,0 +1,44 @@
1
+ import { VoiceError, normalizeAsrModel } from './config.mjs';
2
+ import { transcribe, upstreamFailure } from './media.mjs';
3
+
4
+ /** Normalizes vendor SSE into cumulative replacement text, never append revised text twice. */
5
+ export async function streamTranscription(fetcher, config, bytes, signal, partial) {
6
+ const protocol = config.protocol || 'transcriptions';
7
+ if (protocol === 'transcriptions') return transcribe(fetcher, config, bytes, signal);
8
+ if (!['transcriptions-sse', 'mimo-sse', 'step-sse'].includes(protocol)) throw new VoiceError('This ASR protocol requires a realtime session', 409);
9
+ const audio = Buffer.from(bytes).toString('base64');
10
+ const mimo = protocol === 'mimo-sse';
11
+ const fileStream = protocol === 'transcriptions-sse';
12
+ let body;
13
+ const model = normalizeAsrModel(config.model);
14
+ if (fileStream) { body = new FormData(); body.set('file', new Blob([bytes], { type: 'audio/wav' }), 'segment.wav'); body.set('model', model); body.set('response_format', 'json'); body.set('stream', 'true'); }
15
+ else body = JSON.stringify(mimo ? { model, stream: true, messages: [{ role: 'user', content: [{ type: 'input_audio', input_audio: { data: audio, format: 'wav' } }] }] } : { audio: { data: audio, input: { format: { type: 'wav' }, transcription: { model, language: 'zh', enable_itn: true } } } });
16
+ const upstream = await fetcher(config.baseURL + (fileStream ? '/audio/transcriptions' : mimo ? '/chat/completions' : '/audio/asr/sse'), { method: 'POST', headers: { ...(!fileStream ? { 'content-type': 'application/json' } : {}), accept: 'text/event-stream', ...(config.apiKey ? { authorization: 'Bearer ' + config.apiKey } : {}) }, body, signal, redirect: 'error' });
17
+ if (!upstream.ok) throw await upstreamFailure(upstream, 'ASR', signal);
18
+ if (!upstream.body || !upstream.headers.get('content-type')?.includes('text/event-stream')) { await upstream.body?.cancel(); throw new VoiceError('ASR service must return SSE', 502); }
19
+ const reader = upstream.body.getReader(), decoder = new TextDecoder(); let pending = '', text = '', delivered = '', total = 0, doneEvent = false;
20
+ const abort = () => { reader.cancel().catch(() => {}); }; signal.addEventListener('abort', abort, { once: true });
21
+ const event = packet => {
22
+ const data = packet.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
23
+ if (!data) return; if (data === '[DONE]') { doneEvent = true; return; }
24
+ let value; try { value = JSON.parse(data); } catch { throw new VoiceError('Invalid ASR stream event', 502); }
25
+ if (value.error) throw new VoiceError('ASR streaming request failed', 502);
26
+ if (mimo) { const delta = value.choices?.[0]?.delta?.content; if (typeof delta === 'string') text += delta; if (value.choices?.[0]?.finish_reason) doneEvent = true; }
27
+ else if (value.type === 'transcript.text.delta' && typeof value.delta === 'string') text += value.delta;
28
+ else if (value.type === 'transcript.text.done' && typeof value.text === 'string') { text = value.text; doneEvent = true; }
29
+ if (text.length > 20000) throw new VoiceError('ASR transcript exceeds limit', 502);
30
+ signal.throwIfAborted(); if (text && text !== delivered) { delivered = text; partial(text); }
31
+ };
32
+ try {
33
+ while (true) {
34
+ signal.throwIfAborted(); const result = await reader.read(); signal.throwIfAborted();
35
+ if (result.done) break; total += result.value.length; if (total > 1024 * 1024) throw new VoiceError('ASR stream exceeds limit', 502);
36
+ pending = (pending + decoder.decode(result.value, { stream: true })).replace(/\r\n/gu, '\n');
37
+ let end; while ((end = pending.indexOf('\n\n')) >= 0) { event(pending.slice(0, end)); pending = pending.slice(end + 2); }
38
+ if (pending.length > 65536) throw new VoiceError('ASR event exceeds limit', 502);
39
+ }
40
+ pending += decoder.decode(); if (pending.trim()) event(pending);
41
+ if (!doneEvent) throw new VoiceError('ASR stream ended before final transcription', 502);
42
+ return { text: text.trim() };
43
+ } finally { signal.removeEventListener('abort', abort); await reader.cancel().catch(() => {}); reader.releaseLock(); }
44
+ }