@yfwu2020/dsh-selection-explain 0.10.4 → 0.10.6

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 (120) hide show
  1. package/README.md +13 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +10698 -12424
  4. package/lib/client.js.map +7 -0
  5. package/lib/config-values.js +55 -0
  6. package/lib/config-values.js.map +1 -0
  7. package/lib/host/analyze.js +795 -0
  8. package/lib/host/analyze.js.map +1 -0
  9. package/lib/host/config.js +81 -0
  10. package/lib/host/config.js.map +1 -0
  11. package/lib/host/constants.js +25 -0
  12. package/lib/host/constants.js.map +1 -0
  13. package/lib/host/context.js +237 -0
  14. package/lib/host/context.js.map +1 -0
  15. package/lib/host/design-skill.js +42 -0
  16. package/lib/host/design-skill.js.map +1 -0
  17. package/lib/host/effort.js +239 -0
  18. package/lib/host/effort.js.map +1 -0
  19. package/lib/host/filters.js +132 -0
  20. package/lib/host/filters.js.map +1 -0
  21. package/lib/host/history-store.js +404 -0
  22. package/lib/host/history-store.js.map +1 -0
  23. package/lib/host/http.js +46 -0
  24. package/lib/host/http.js.map +1 -0
  25. package/lib/host/messages.js +14 -0
  26. package/lib/host/messages.js.map +1 -0
  27. package/lib/host/mic.js +246 -0
  28. package/lib/host/mic.js.map +1 -0
  29. package/lib/host/models.js +246 -0
  30. package/lib/host/models.js.map +1 -0
  31. package/lib/host/plugin.js +41 -0
  32. package/lib/host/plugin.js.map +1 -0
  33. package/lib/host/promote.js +219 -0
  34. package/lib/host/promote.js.map +1 -0
  35. package/lib/host/routes.js +237 -0
  36. package/lib/host/routes.js.map +1 -0
  37. package/lib/host/sessions.js +181 -0
  38. package/lib/host/sessions.js.map +1 -0
  39. package/lib/host/settings-store.js +140 -0
  40. package/lib/host/settings-store.js.map +1 -0
  41. package/lib/host/speech.js +320 -0
  42. package/lib/host/speech.js.map +1 -0
  43. package/lib/host/tools.js +179 -0
  44. package/lib/host/tools.js.map +1 -0
  45. package/lib/host/types.js +2 -0
  46. package/lib/host/types.js.map +1 -0
  47. package/lib/index.js +10 -3765
  48. package/lib/index.js.map +1 -1
  49. package/lib/prompts.js +1 -1
  50. package/lib/settings.js +17 -27
  51. package/lib/settings.js.map +1 -1
  52. package/lib/types/config-values.d.ts +175 -0
  53. package/lib/types/host/analyze.d.ts +10 -0
  54. package/lib/types/host/config.d.ts +147 -0
  55. package/lib/types/host/constants.d.ts +24 -0
  56. package/lib/types/host/context.d.ts +41 -0
  57. package/lib/types/host/design-skill.d.ts +1 -0
  58. package/lib/types/host/effort.d.ts +61 -0
  59. package/lib/types/host/filters.d.ts +20 -0
  60. package/lib/types/host/history-store.d.ts +13 -0
  61. package/lib/types/host/http.d.ts +8 -0
  62. package/lib/types/host/messages.d.ts +13 -0
  63. package/lib/types/host/mic.d.ts +17 -0
  64. package/lib/types/host/models.d.ts +57 -0
  65. package/lib/types/host/plugin.d.ts +3 -0
  66. package/lib/types/host/promote.d.ts +9 -0
  67. package/lib/types/host/routes.d.ts +14 -0
  68. package/lib/types/host/sessions.d.ts +14 -0
  69. package/lib/types/host/settings-store.d.ts +8 -0
  70. package/lib/types/host/speech.d.ts +23 -0
  71. package/lib/types/host/tools.d.ts +39 -0
  72. package/lib/types/host/types.d.ts +89 -0
  73. package/lib/types/index.d.ts +12 -441
  74. package/lib/types/prompts.d.ts +1 -1
  75. package/lib/types/settings.d.ts +1 -1
  76. package/package.json +31 -21
  77. package/assets/code.png +0 -0
  78. package/assets/detail.png +0 -0
  79. package/assets/quotes.png +0 -0
  80. package/assets/stage1.png +0 -0
  81. package/assets/stage2.png +0 -0
  82. package/assets/translation.png +0 -0
  83. package/assets/voice-card-light.png +0 -0
  84. package/assets/voice-inserted.png +0 -0
  85. package/assets/voice-settings.png +0 -0
  86. package/assets/voice.png +0 -0
  87. package/assets/web.png +0 -0
  88. package/scripts/build-demo-pages.mjs +0 -350
  89. package/scripts/build-vcard-lab.mjs +0 -834
  90. package/scripts/build.sh +0 -147
  91. package/scripts/crop-png.mjs +0 -142
  92. package/scripts/dump-prompt.mjs +0 -143
  93. package/scripts/fixtures/fake-mic-probe.sh +0 -9
  94. package/scripts/gen-star-path.mjs +0 -71
  95. package/scripts/measure-cost.mjs +0 -251
  96. package/scripts/smoke-bridge.mjs +0 -365
  97. package/scripts/smoke-panel.mjs +0 -1903
  98. package/scripts/smoke-voice.mjs +0 -774
  99. package/scripts/test-bridge.mjs +0 -412
  100. package/scripts/test-client.mjs +0 -6175
  101. package/scripts/test-effort-map.mjs +0 -129
  102. package/scripts/test-effort-retry.mjs +0 -109
  103. package/scripts/test-escape-focus.mjs +0 -101
  104. package/scripts/test-filters.mjs +0 -146
  105. package/scripts/test-format.mjs +0 -93
  106. package/scripts/test-guard.mjs +0 -48
  107. package/scripts/test-mic-build.mjs +0 -146
  108. package/scripts/test-mic-idle.mjs +0 -158
  109. package/scripts/test-model-follow.mjs +0 -193
  110. package/scripts/test-prompt.mjs +0 -193
  111. package/scripts/test-route-cache.mjs +0 -138
  112. package/scripts/test-settings-behavior.mjs +0 -188
  113. package/scripts/test-settings-host.mjs +0 -48
  114. package/scripts/test-settings.mjs +0 -329
  115. package/scripts/test-speech.mjs +0 -220
  116. package/scripts/test-transcript.mjs +0 -151
  117. package/src/client/index.js +0 -12580
  118. package/src/index.ts +0 -4254
  119. package/src/prompts.ts +0 -144
  120. package/src/settings.ts +0 -313
@@ -0,0 +1,181 @@
1
+ import { name } from './constants.js';
2
+ import { transcriptOf } from './context.js';
3
+ /** Owns sessions state for one plugin instance. */
4
+ export function createSessions(ctx, config) {
5
+ /** 会话背景短缓存:同一会话 10 秒内复用(连续划词不重复读日志)。 */
6
+ const backgroundCache = new Map();
7
+ /**
8
+ * 「本会话在用哪个模型」的短缓存 —— 只给按秒轮询的 `/route` 用(见 sessionModelOf)。
9
+ *
10
+ * 为什么需要:`readSession` 会把整份日志重放成一个 Session、再逐条深拷贝一遍
11
+ * (本机实测 1.5MB 的会话一次 80~125ms)。跟随状态下**小窗一开着**就要每 2 秒问一次,
12
+ * 不缓存等于每 2 秒在宿主进程里重放一次会话日志。
13
+ *
14
+ * 变更探测用 live 会话的 `seq`(同步、零成本,见 liveSeqOf):没涨就直接回缓存;
15
+ * 涨了(主会话正在连续对话时每轮都在涨)也至少隔 SESSION_MODEL_MIN_MS 才重读一次,
16
+ * 否则"小窗开着 + 主会话在聊"时每 2 秒照样重放。非 live(磁盘上的旧会话,读不到 seq)按 TTL 兜底。
17
+ */
18
+ const sessionModelCache = new Map();
19
+ /** 同一会话正在飞的读取(读可能比轮询间隔还慢,叠起来没意义)。 */
20
+ const sessionModelPending = new Map();
21
+ /** live 会话两次重读之间的最短间隔(挡住"事件一直在涨"时的连续重放)。 */
22
+ const SESSION_MODEL_MIN_MS = 3_000;
23
+ /** 读不到 seq(非 live)时的兜底 TTL。 */
24
+ const SESSION_MODEL_TTL_MS = 10_000;
25
+ /**
26
+ * live 会话的事件数(`Session.seq` = 日志长度,每次 append 都会涨)。
27
+ *
28
+ * 这是**零成本**的同步读取:不用读日志、不用重放,就能判断"会话有没有新事件"。
29
+ * 拿不到(会话不在内存里 / 老内核没有 sessions 服务)返回 null,调用方退回 TTL。
30
+ */
31
+ const liveSeqOf = (sessionId) => {
32
+ const sessions = ctx.get('sessions');
33
+ const seq = sessions?.get?.(sessionId)?.seq;
34
+ return typeof seq === 'number' ? seq : null;
35
+ };
36
+ /** 读取当前会话的模型界面事件(live 优先;失败/超时一律降级为「无背景」)。 */
37
+ const readSurfaceEvents = async (sessionId) => {
38
+ const cached = backgroundCache.get(sessionId);
39
+ if (cached && Date.now() - cached.at < 10_000)
40
+ return cached.events;
41
+ const sessionQuery = ctx.get('sessionQuery');
42
+ if (typeof sessionQuery?.readSurface !== 'function')
43
+ return [];
44
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 2000));
45
+ try {
46
+ const surface = await Promise.race([sessionQuery.readSurface(sessionId), timeout]);
47
+ const events = surface && Array.isArray(surface.events) ? surface.events : [];
48
+ backgroundCache.set(sessionId, { at: Date.now(), events });
49
+ if (backgroundCache.size > 8) {
50
+ const oldest = backgroundCache.keys().next().value;
51
+ if (oldest !== undefined)
52
+ backgroundCache.delete(oldest);
53
+ }
54
+ return events;
55
+ }
56
+ catch (error) {
57
+ ctx.logger?.warn?.(`[${name}] 读取会话背景失败(${sessionId}):${String(error?.message ?? error)}`);
58
+ return [];
59
+ }
60
+ };
61
+ /**
62
+ * 真读一次:拿会话原始事件里的最后一条 `model/selection`。
63
+ *
64
+ * 来源:会话事件流里的 `model/selection`(每条 = 一次"后续请求用这个模型"的选择,
65
+ * 形如 `{ type:'model/selection', data:{ provider, model, reasoningEffort? } }`)。
66
+ * 取**最后一条** = 会话当前在用的那个;一条都没有(新会话)就返回 null,调用方落回全局默认。
67
+ */
68
+ const readSessionModel = async (sessionId) => {
69
+ if (!sessionId)
70
+ return null;
71
+ const sessionQuery = ctx.get('sessionQuery');
72
+ if (typeof sessionQuery?.readSession !== 'function')
73
+ return null;
74
+ let events = [];
75
+ try {
76
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 2000));
77
+ const log = await Promise.race([sessionQuery.readSession(sessionId), timeout]);
78
+ events = log && Array.isArray(log.events) ? (log.events) : [];
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ for (let i = events.length - 1; i >= 0; i -= 1) {
84
+ const event = events[i];
85
+ if (!event || typeof event !== 'object')
86
+ continue;
87
+ const typed = event;
88
+ if (typed.type !== 'model/selection')
89
+ continue;
90
+ const data = (typed.data ?? {});
91
+ if (typeof data.provider === 'string' && data.provider && typeof data.model === 'string' && data.model) {
92
+ return { provider: data.provider, model: data.model };
93
+ }
94
+ }
95
+ return null;
96
+ };
97
+ /**
98
+ * 记下这次的结论(连同"读到它时的 seq"):下次轮询就能靠 seq 判断结论还有效没。
99
+ *
100
+ * 顺手也把"用户动作路径"读到的值种进缓存 —— 那是**此刻**那一份,
101
+ * 种进来只会让轮询更少重读、更不容易报旧值。
102
+ */
103
+ const rememberSessionModel = (sessionId, seq, value) => {
104
+ sessionModelCache.set(sessionId, { at: Date.now(), seq, value });
105
+ if (sessionModelCache.size > 8) {
106
+ const oldest = sessionModelCache.keys().next().value;
107
+ if (oldest !== undefined)
108
+ sessionModelCache.delete(oldest);
109
+ }
110
+ };
111
+ /**
112
+ * 读**当前会话**自己的模型选择。
113
+ *
114
+ * `useCache` 只给按秒轮询的 `/route` 打开;用户动作路径(analyze / models / ping)一律直读,
115
+ * 拿到的必须是**此刻**那一份 —— 不能让人刚在主会话里换完模型、划词却按旧模型解析。
116
+ * 直读的结果会顺手种进缓存(见 rememberSessionModel)。
117
+ */
118
+ const sessionModelOf = async (sessionId, useCache = false) => {
119
+ if (!sessionId)
120
+ return null;
121
+ if (!useCache) {
122
+ // seq 在读**之前**取:读的过程中若又落了事件,缓存就应当被视为旧值(宁可下次重读)
123
+ const seqNow = liveSeqOf(sessionId);
124
+ const value = await readSessionModel(sessionId);
125
+ rememberSessionModel(sessionId, seqNow, value);
126
+ return value;
127
+ }
128
+ const seq = liveSeqOf(sessionId);
129
+ const cached = sessionModelCache.get(sessionId);
130
+ if (cached) {
131
+ // 事件没涨 → 结论不可能变。只对 live 会话成立:`seq` 是同步的,每次 append 都会涨。
132
+ if (seq !== null && cached.seq === seq)
133
+ return cached.value;
134
+ // 涨了也不立刻重读:主会话正在连续对话时事件一直在涨,否则每 2 秒照样重放一次日志。
135
+ const floor = seq === null ? SESSION_MODEL_TTL_MS : SESSION_MODEL_MIN_MS;
136
+ if (Date.now() - cached.at < floor)
137
+ return cached.value;
138
+ }
139
+ // 同一会话的轮询叠在一起(读可能比轮询间隔还慢)时共用一个在飞的请求
140
+ const pending = sessionModelPending.get(sessionId);
141
+ if (pending)
142
+ return pending;
143
+ const task = readSessionModel(sessionId)
144
+ .then((value) => {
145
+ rememberSessionModel(sessionId, seq, value);
146
+ return value;
147
+ })
148
+ .finally(() => {
149
+ sessionModelPending.delete(sessionId);
150
+ });
151
+ sessionModelPending.set(sessionId, task);
152
+ return task;
153
+ };
154
+ /** 组装会话背景文本。 */
155
+ const loadSessionBackground = async (sessionId, options) => {
156
+ const events = await readSurfaceEvents(sessionId);
157
+ if (events.length === 0)
158
+ return null;
159
+ const background = transcriptOf(events, options);
160
+ return background.transcript ? background : null;
161
+ };
162
+ /** 解析一个会话的工作目录(同项目 = 同 cwd)。 */
163
+ const resolveSessionCwd = async (sessionId) => {
164
+ if (!sessionId)
165
+ return undefined;
166
+ const sessions = ctx.get('sessions');
167
+ const live = sessions?.get?.(sessionId)?.header?.cwd;
168
+ if (live)
169
+ return live;
170
+ const sessionQuery = ctx.get('sessionQuery');
171
+ try {
172
+ const log = await sessionQuery?.readSession?.(sessionId);
173
+ return log?.session?.cwd;
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ };
179
+ return { sessionModelOf, loadSessionBackground, resolveSessionCwd, readSurfaceEvents };
180
+ }
181
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.js","sourceRoot":"","sources":["../../src/host/sessions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,140 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { SETTINGS_KEYS, PERSISTED_SETTING_KEYS, normalizeStoredSettings, normalizeSettingsPatch, buildSettingsPayload } from '../settings.js';
4
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
5
+ import { name } from './constants.js';
6
+ import { readBody, sendJson } from './http.js';
7
+ /** Owns settings-store state for one plugin instance. */
8
+ export function createSettingsStore(ctx, config) {
9
+ /**
10
+ * 出厂默认值(**不含**用户在设置页改过的部分)。
11
+ *
12
+ * 为什么单独留一份:设置页的「恢复默认」要能把值写回这里,
13
+ * 而"这一项改过没有"也要拿它对比 —— 只有一个 config 的话,改完就再也说不出原值是什么。
14
+ * 注意它取的是 rawConfig(cordis.patch.yml 里的配置),所以 yml 里写过的值也算"默认":
15
+ * 用户在设置页恢复默认,应该回到 yml 写的那个数,而不是硬编码的出厂数。
16
+ */
17
+ const configDefaults = { ...config };
18
+ /**
19
+ * 设置页写入的覆盖值(**只存改过的键**)。
20
+ *
21
+ * 落盘在插件自己的目录(与 history.json / pill.json 同级),不碰 cordis.patch.yml:
22
+ * 那个文件是**装配清单**,让 UI 去改它既容易写坏结构,也要重启才生效。
23
+ * 这里的内存值即刻生效(下方所有 config.x 都是每次请求现读的),落盘只为跨重启保留。
24
+ */
25
+ let settingsOverrides = {};
26
+ let settingsLoaded = false;
27
+ let settingsTimer = null;
28
+ const settingsFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'settings.json');
29
+ /**
30
+ * 把覆盖值套用到 config 上。
31
+ *
32
+ * 只接受 SETTINGS_KEYS 里的键,并且**类型必须与默认值一致** ——
33
+ * 文件是手工可编辑的(也可能被旧版本写坏),不能让它把 boolean 写成字符串,
34
+ * 那会让 `if (config.tools)` 这种判断悄悄变成永真。
35
+ */
36
+ function applyOverrides(patch) {
37
+ const target = config;
38
+ for (const [key, value] of Object.entries(patch)) {
39
+ if (!SETTINGS_KEYS.includes(key) && !PERSISTED_SETTING_KEYS.includes(key))
40
+ continue;
41
+ if (typeof value !== typeof configDefaults[key])
42
+ continue;
43
+ target[key] = value;
44
+ }
45
+ }
46
+ /** 读设置覆盖文件(进程内只读一次,与 loadHistory / loadPillPos 同节奏)。 */
47
+ async function loadSettings() {
48
+ if (settingsLoaded)
49
+ return;
50
+ settingsLoaded = true;
51
+ try {
52
+ const parsed = JSON.parse(await readFile(settingsFile(), 'utf8'));
53
+ const raw = parsed?.values;
54
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
55
+ const values = normalizeStoredSettings(raw);
56
+ settingsOverrides = values;
57
+ applyOverrides(values);
58
+ }
59
+ }
60
+ catch {
61
+ // 没存过 / 坏了都当作"没改过":出厂默认本来就是可用状态,没必要因此报错
62
+ settingsOverrides = {};
63
+ }
64
+ }
65
+ /** 落盘(防抖:拖数字输入框会连发多次,没必要每次都写文件)。 */
66
+ function scheduleSettingsWrite() {
67
+ if (settingsTimer)
68
+ clearTimeout(settingsTimer);
69
+ settingsTimer = setTimeout(() => {
70
+ settingsTimer = null;
71
+ void (async () => {
72
+ try {
73
+ await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
74
+ await writeFile(settingsFile(), JSON.stringify({ version: 1, values: settingsOverrides }, null, 0), 'utf8');
75
+ }
76
+ catch (error) {
77
+ ctx.logger?.warn?.(`[${name}] 写入设置失败:${String(error?.message ?? error)}`);
78
+ }
79
+ })();
80
+ }, 300);
81
+ settingsTimer.unref?.();
82
+ }
83
+ /** 当前生效值(按 spec 的键序取,客户端可以直接对比 defaults 判断改过没有)。 */
84
+ function currentSettings() {
85
+ const target = config;
86
+ const values = {};
87
+ for (const key of SETTINGS_KEYS)
88
+ values[key] = target[key];
89
+ return values;
90
+ }
91
+ /**
92
+ * 设置页的读写口。
93
+ *
94
+ * GET → { groups, values, defaults }:spec 一并下发,客户端只负责画
95
+ * POST → { values: {...} }:归一 → 立刻生效(改内存)→ 防抖落盘
96
+ * POST { reset: true } :清空覆盖,回到 cordis.patch.yml / 出厂默认
97
+ *
98
+ * 为什么不是"写回 cordis.patch.yml":那是装配清单,
99
+ * 让 UI 去改它既要人肉重启、又可能把 YAML 结构写坏;这里落在插件自己的目录,改完即时生效。
100
+ */
101
+ const handleSettings = (req, res) => {
102
+ void (async () => {
103
+ await loadSettings();
104
+ if (req.method === 'POST') {
105
+ const raw = await readBody(req, 200_000);
106
+ let body = {};
107
+ try {
108
+ body = raw ? JSON.parse(raw) : {};
109
+ }
110
+ catch {
111
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
112
+ return;
113
+ }
114
+ if (body.reset === true) {
115
+ settingsOverrides = {};
116
+ applyOverrides(configDefaults);
117
+ scheduleSettingsWrite();
118
+ sendJson(res, 200, { ok: true, values: currentSettings(), rejected: [] });
119
+ return;
120
+ }
121
+ const result = normalizeSettingsPatch(body.values);
122
+ if (Object.keys(result.values).length === 0 && result.rejected.length > 0) {
123
+ sendJson(res, 400, { ok: false, error: '没有可写入的字段', rejected: result.rejected });
124
+ return;
125
+ }
126
+ // 合并而不是覆盖:只发一个键的补丁不该把其它改过的项抹掉
127
+ settingsOverrides = { ...settingsOverrides, ...result.values };
128
+ applyOverrides(result.values);
129
+ scheduleSettingsWrite();
130
+ sendJson(res, 200, { ok: true, values: currentSettings(), rejected: result.rejected });
131
+ return;
132
+ }
133
+ sendJson(res, 200, { ok: true, ...buildSettingsPayload(currentSettings(), configDefaults) });
134
+ })().catch((error) => {
135
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
136
+ });
137
+ };
138
+ return { loadSettings, handleSettings };
139
+ }
140
+ //# sourceMappingURL=settings-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-store.js","sourceRoot":"","sources":["../../src/host/settings-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,320 @@
1
+ import { sendJson, readBody } from './http.js';
2
+ // ────────────────────────────── 语音输入(小窗麦克风) ──────────────────────────────
3
+ //
4
+ // 小窗的追问输入框加一个麦克风:录一段话 → 转成文字**插进输入框**(不是直接发送,
5
+ // 识别错了还能改)。转写这条链路在 host 这一侧收口:
6
+ //
7
+ // GET ${API_PREFIX}/speech 识别服务目录 + 就绪状态(客户端据此决定麦克风能不能点)
8
+ // POST ${API_PREFIX}/speech/transcribe 一段 16kHz 单声道 PCM16 WAV(base64)→ 文字
9
+ // POST ${API_PREFIX}/speech/prepare 显式准备本地识别模型(首次要下载,必须用户点头)
10
+ //
11
+ // 为什么不让浏览器自己去连识别服务:音频、provider 选择、模型路径全部留在 host,
12
+ // 客户端只拿到"一段话的文字";而且**音频不落盘、不进会话**(转写完即丢)。
13
+ // 识别服务(@deepseek-ai/dsh-experimental-speech-to-text)是实验性的、可以不装:
14
+ // 拿不到该服务就整条链路优雅降级(目录接口回 available:false,麦克风点了只给一句解释)。
15
+ /** 单次录音时长上限(秒):到了就自动收尾去识别,不让用户对着麦克风发呆。 */
16
+ const SPEECH_MAX_SECONDS = 60;
17
+ /** 单条音频的字节上限(≈ 2 分钟 16kHz 单声道 PCM16);与识别服务默认的 4MB 对齐。 */
18
+ const SPEECH_MAX_BYTES = 4 * 1024 * 1024;
19
+ /** 识别请求体上限:base64 会膨胀 4/3,再留点余量给 JSON 外壳。 */
20
+ const SPEECH_BODY_LIMIT = Math.ceil(SPEECH_MAX_BYTES / 3) * 4 + 4096;
21
+ /** 单次识别的等待上限(毫秒):真机上 60 秒音频几秒内就回来了,这是兜底。 */
22
+ const SPEECH_TIMEOUT_MS = 120_000;
23
+ /**
24
+ * 校验客户端送上来的音频是不是**规范**的 16kHz 单声道 PCM16 WAV。
25
+ *
26
+ * 为什么抠得这么死:这个格式是浏览器端编码器(MediaRecorder → decodeAudioData →
27
+ * OfflineAudioContext 重采样 → 手写 WAV 头)唯一会产出的形状。逐字段对齐之后,
28
+ * "音频不对"就只剩两种可能——客户端版本对不上、或者有人手工构造了这个请求。
29
+ * 光看二进制长度不算数:头里写的采样率/声道/位深必须是那一套,否则识别服务会按
30
+ * 错误的采样率解释这段音频(听起来像快放/慢放),识别结果自然全错。
31
+ *
32
+ * @param audio 完整音频字节
33
+ * @param maxSeconds 允许的最长时长
34
+ * @returns 时长(秒)
35
+ */
36
+ export function validateWave(audio, maxSeconds) {
37
+ if (audio.length < 46 ||
38
+ audio.toString('ascii', 0, 4) !== 'RIFF' ||
39
+ audio.toString('ascii', 8, 12) !== 'WAVE' ||
40
+ audio.toString('ascii', 12, 16) !== 'fmt ' ||
41
+ audio.readUInt32LE(16) !== 16 ||
42
+ audio.readUInt16LE(20) !== 1 || // PCM
43
+ audio.readUInt16LE(22) !== 1 || // 单声道
44
+ audio.readUInt32LE(24) !== 16000 || // 16kHz
45
+ audio.readUInt32LE(28) !== 32000 || // 字节率 = 16000 × 1ch × 2B
46
+ audio.readUInt16LE(32) !== 2 || // 块对齐
47
+ audio.readUInt16LE(34) !== 16 || // 16 bit
48
+ audio.toString('ascii', 36, 40) !== 'data' ||
49
+ audio.readUInt32LE(4) !== audio.length - 8 ||
50
+ audio.readUInt32LE(40) !== audio.length - 44 ||
51
+ (audio.length - 44) % 2 !== 0) {
52
+ throw new Error('音频格式不是 16kHz 单声道 PCM16 WAV');
53
+ }
54
+ const seconds = (audio.length - 44) / 32000;
55
+ if (seconds <= 0)
56
+ throw new Error('录音是空的');
57
+ if (seconds > maxSeconds)
58
+ throw new Error(`录音超过 ${maxSeconds} 秒`);
59
+ return seconds;
60
+ }
61
+ /** Owns speech state for one plugin instance. */
62
+ export function createSpeech(ctx, config) {
63
+ /** ctx.speechToText:装了就给,没装/被停用给 undefined(麦克风据此优雅降级)。 */
64
+ const speechService = () => {
65
+ try {
66
+ const service = ctx.get('speechToText');
67
+ return service && typeof service.transcribe === 'function' ? service : undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ };
73
+ /**
74
+ * 语音目录:有哪些识别器、各自什么状态、默认选谁、这次能录多久。
75
+ *
76
+ * 客户端据此决定麦克风的形态:能用 / 要先把模型准备好 / 这个部署根本没有识别服务。
77
+ * 不认识的状态一律当成"不能录"(宁可让用户看到一句解释,也不要录完才发现转不了)。
78
+ */
79
+ const speechCatalog = () => {
80
+ const service = speechService();
81
+ const limits = { maxSeconds: SPEECH_MAX_SECONDS, maxBytes: SPEECH_MAX_BYTES };
82
+ if (!service) {
83
+ return {
84
+ ok: true,
85
+ available: false,
86
+ reason: 'missing',
87
+ error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
88
+ providers: [],
89
+ selection: null,
90
+ limits,
91
+ };
92
+ }
93
+ let snapshot;
94
+ try {
95
+ snapshot = service.snapshot();
96
+ }
97
+ catch (error) {
98
+ return {
99
+ ok: true,
100
+ available: false,
101
+ reason: 'error',
102
+ error: String(error?.message ?? error),
103
+ providers: [],
104
+ selection: null,
105
+ limits,
106
+ };
107
+ }
108
+ const providers = (snapshot?.providers ?? []).map((provider) => {
109
+ const preparation = provider.preparation ?? { phase: 'ready' };
110
+ return {
111
+ id: String(provider.id),
112
+ name: String(provider.name ?? provider.id),
113
+ location: String(provider.location ?? ''),
114
+ languages: Array.isArray(provider.languages) ? provider.languages.map(String) : [],
115
+ // 下载源:本地模型已经装好时是空数组(没有可选项),装了才有得选
116
+ downloadSources: Array.isArray(provider.downloadSources) ? provider.downloadSources.map(String) : [],
117
+ phase: String(preparation.phase ?? 'unprepared'),
118
+ step: preparation.step ? String(preparation.step) : '',
119
+ // 下载进度(只在 downloading 时有意义):客户端显示"还要下多少"
120
+ completedBytes: typeof preparation.completedBytes === 'number' ? preparation.completedBytes : 0,
121
+ totalBytes: typeof preparation.totalBytes === 'number' ? preparation.totalBytes : 0,
122
+ message: preparation.message ? String(preparation.message) : '',
123
+ };
124
+ });
125
+ const selection = snapshot?.selection
126
+ ? { providerId: String(snapshot.selection.providerId), language: String(snapshot.selection.language) }
127
+ : null;
128
+ // 能录 = 至少有一个识别器已经就绪(standby 也算:本机模型在磁盘上,用到才唤醒)
129
+ const usable = providers.filter((provider) => provider.phase === 'ready' || provider.phase === 'standby');
130
+ return {
131
+ ok: true,
132
+ available: usable.length > 0,
133
+ reason: usable.length > 0 ? '' : providers.length > 0 ? 'unprepared' : 'empty',
134
+ error: '',
135
+ providers,
136
+ selection,
137
+ limits,
138
+ };
139
+ };
140
+ const handleSpeech = (req, res) => {
141
+ if (req.method !== 'GET') {
142
+ sendJson(res, 405, { ok: false, error: '只支持 GET' });
143
+ return;
144
+ }
145
+ sendJson(res, 200, speechCatalog());
146
+ };
147
+ /**
148
+ * 显式准备识别模型(首次要下 1G 左右的模型,所以**只有用户点了才做**,不自动触发)。
149
+ * 只是把任务交给识别服务,进度走目录接口轮询(下载在 host 上跑,关掉页面也继续)。
150
+ */
151
+ const handleSpeechPrepare = (req, res) => {
152
+ void (async () => {
153
+ if (req.method !== 'POST') {
154
+ sendJson(res, 405, { ok: false, error: '只支持 POST' });
155
+ return;
156
+ }
157
+ const service = speechService();
158
+ if (!service) {
159
+ sendJson(res, 200, { ok: false, error: '这个 DSH 没有启用语音识别服务' });
160
+ return;
161
+ }
162
+ let body = {};
163
+ try {
164
+ const raw = await readBody(req, 20_000);
165
+ body = raw ? JSON.parse(raw) : {};
166
+ }
167
+ catch {
168
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
169
+ return;
170
+ }
171
+ const catalog = speechCatalog();
172
+ const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
173
+ const providers = catalog.providers ?? [];
174
+ const target = wanted || (providers.find((item) => item.phase !== 'ready' && item.phase !== 'standby')?.id ?? providers[0]?.id ?? '');
175
+ if (!target) {
176
+ sendJson(res, 200, { ok: false, error: '没有可用的识别器' });
177
+ return;
178
+ }
179
+ try {
180
+ const source = typeof body.downloadSource === 'string' && body.downloadSource ? body.downloadSource : undefined;
181
+ service.prepare(target, source === undefined ? undefined : { downloadSource: source });
182
+ }
183
+ catch (error) {
184
+ // 已经在准备中 / 下载源不认 / 服务生命周期已结束:都不算致命,把状态回给客户端自己看
185
+ sendJson(res, 200, { ok: false, error: String(error?.message ?? error), catalog: speechCatalog() });
186
+ return;
187
+ }
188
+ sendJson(res, 200, { ok: true, providerId: target, catalog: speechCatalog() });
189
+ })().catch((error) => {
190
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
191
+ });
192
+ };
193
+ /**
194
+ * 转写一段录音。
195
+ *
196
+ * 输入是浏览器端编码好的规范 WAV(base64),输出就是一段文字——**不落盘、不进会话**,
197
+ * 也不给模型看(用户要的是"把我说的变成字",不是"让模型处理我的录音")。
198
+ * 识别失败一律回 200 + { ok:false, code }:这是"这一句话没听清",
199
+ * 不是 HTTP 层面的错误,客户端按 code 给不同的话术(换 HTTP 状态码反而会被
200
+ * 各种中间层改写或吞掉,用户看到的就只剩"请求失败")。
201
+ */
202
+ const handleSpeechTranscribe = (req, res) => {
203
+ void (async () => {
204
+ if (req.method !== 'POST') {
205
+ sendJson(res, 405, { ok: false, error: '只支持 POST' });
206
+ return;
207
+ }
208
+ const service = speechService();
209
+ if (!service) {
210
+ sendJson(res, 200, {
211
+ ok: false,
212
+ code: 'missing',
213
+ error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
214
+ });
215
+ return;
216
+ }
217
+ let body = {};
218
+ try {
219
+ const raw = await readBody(req, SPEECH_BODY_LIMIT);
220
+ body = raw ? JSON.parse(raw) : {};
221
+ }
222
+ catch (error) {
223
+ sendJson(res, 200, { ok: false, code: 'transport', error: String(error?.message ?? error) });
224
+ return;
225
+ }
226
+ const encoded = typeof body.audioBase64 === 'string' ? body.audioBase64 : '';
227
+ if (!encoded) {
228
+ sendJson(res, 200, { ok: false, code: 'audio', error: '没有收到音频' });
229
+ return;
230
+ }
231
+ let audio;
232
+ let seconds = 0;
233
+ try {
234
+ if (encoded.length > Math.ceil(SPEECH_MAX_BYTES / 3) * 4)
235
+ throw new Error('录音太大了');
236
+ audio = Buffer.from(encoded, 'base64');
237
+ // 规范 base64:随手传一段别的东西进来会在这一步被挡掉(解出来对不上原文)
238
+ if (audio.toString('base64') !== encoded)
239
+ throw new Error('音频不是合法 base64');
240
+ if (audio.length > SPEECH_MAX_BYTES)
241
+ throw new Error('录音太大了');
242
+ seconds = validateWave(audio, SPEECH_MAX_SECONDS);
243
+ }
244
+ catch (error) {
245
+ sendJson(res, 200, { ok: false, code: 'audio', error: String(error?.message ?? error) });
246
+ return;
247
+ }
248
+ const catalog = speechCatalog();
249
+ const providers = catalog.providers ?? [];
250
+ // 这个部署默认用的语言(识别服务里配的,auto = 自动判断);请求里带了就以请求为准
251
+ const catalogLanguage = String(catalog.selection?.language ?? '');
252
+ const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
253
+ const picked = providers.find((item) => item.id === wanted) ??
254
+ providers.find((item) => item.phase === 'ready' || item.phase === 'standby') ??
255
+ providers[0];
256
+ if (!picked) {
257
+ sendJson(res, 200, { ok: false, code: 'empty', error: '没有可用的识别器' });
258
+ return;
259
+ }
260
+ if (picked.phase !== 'ready' && picked.phase !== 'standby') {
261
+ sendJson(res, 200, {
262
+ ok: false,
263
+ code: 'unprepared',
264
+ providerId: picked.id,
265
+ phase: picked.phase,
266
+ error: `识别模型还没准备好(${picked.name}:${picked.phase})`,
267
+ catalog,
268
+ });
269
+ return;
270
+ }
271
+ // 客户端断开 / 用户取消 → 立刻掐掉识别(本机模型在跑推理,不能让它白跑)
272
+ const controller = new AbortController();
273
+ const timer = setTimeout(() => controller.abort(new Error('识别超时')), SPEECH_TIMEOUT_MS);
274
+ const onClose = () => controller.abort(new Error('客户端已断开'));
275
+ req.on('close', onClose);
276
+ const startedAt = Date.now();
277
+ try {
278
+ const language = typeof body.language === 'string' && body.language ? body.language : undefined;
279
+ const spec = service.resolve({
280
+ audio,
281
+ ...(wanted ? { providerId: wanted } : {}),
282
+ ...(language === undefined ? {} : { language }),
283
+ });
284
+ const transcript = await service.transcribe(spec, controller.signal);
285
+ const text = String(transcript?.text ?? '').trim();
286
+ if (!text) {
287
+ sendJson(res, 200, { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' });
288
+ return;
289
+ }
290
+ sendJson(res, 200, {
291
+ ok: true,
292
+ text,
293
+ providerId: picked.id,
294
+ providerName: picked.name,
295
+ language: language ?? catalogLanguage,
296
+ seconds,
297
+ elapsedMs: Date.now() - startedAt,
298
+ });
299
+ }
300
+ catch (error) {
301
+ const message = String(error?.message ?? error);
302
+ const aborted = error?.name === 'AbortError' || controller.signal.aborted;
303
+ // 超时/断开与"识别失败"分开报:前者是这一句没转成,后者是识别服务本身有问题
304
+ sendJson(res, 200, {
305
+ ok: false,
306
+ code: aborted ? 'aborted' : 'failed',
307
+ error: aborted ? `这次识别被打断了(${message})` : message,
308
+ });
309
+ }
310
+ finally {
311
+ clearTimeout(timer);
312
+ req.off('close', onClose);
313
+ }
314
+ })().catch((error) => {
315
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
316
+ });
317
+ };
318
+ return { handleSpeech, handleSpeechTranscribe, handleSpeechPrepare };
319
+ }
320
+ //# sourceMappingURL=speech.js.map