@yfwu2020/dsh-selection-explain 0.10.3 → 0.10.5

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 (119) hide show
  1. package/README.md +13 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +10694 -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 -3691
  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-idle.mjs +0 -158
  108. package/scripts/test-model-follow.mjs +0 -193
  109. package/scripts/test-prompt.mjs +0 -193
  110. package/scripts/test-route-cache.mjs +0 -138
  111. package/scripts/test-settings-behavior.mjs +0 -188
  112. package/scripts/test-settings-host.mjs +0 -48
  113. package/scripts/test-settings.mjs +0 -329
  114. package/scripts/test-speech.mjs +0 -220
  115. package/scripts/test-transcript.mjs +0 -151
  116. package/src/client/index.js +0 -12580
  117. package/src/index.ts +0 -4179
  118. package/src/prompts.ts +0 -144
  119. package/src/settings.ts +0 -313
package/lib/index.js CHANGED
@@ -1,3694 +1,13 @@
1
- /**
2
- * @yfwu2020/dsh-selection-explain — host half(划词解读)。
3
- *
4
- * 为浏览器端的「划词解读」提供两个同源 HTTP 路由:
5
- * POST /selection-explain/api/analyze 选中文字 + 上下文 → SSE 流式返回
6
- * 「专业中英翻译」与「语境含义详解」
7
- * GET /selection-explain/api/ping 健康检查:当前模型路由 + 限制(弹窗页脚用)
8
- *
9
- * 模型路由默认取 agentDefaultModel.currentSelection()(用户在设置里选的默认模型);
10
- * 调用过一次真实 LLM 后,也会从 llm/stream 事件里捕获路由作为兜底。
11
- * 流式 text-delta 直接透传,前端边收边渲染,首字延迟 ≈ 模型首 token 时间。
12
- */
13
- import { createHash } from 'node:crypto';
14
- import { lookup } from 'node:dns/promises';
15
- import { spawn } from 'node:child_process';
16
- import { existsSync } from 'node:fs';
17
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
18
- import { homedir } from 'node:os';
19
- import { join, resolve, sep } from 'node:path';
20
- import { createInterface } from 'node:readline';
21
- import { fileURLToPath } from 'node:url';
22
- import { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
23
- import z from '@deepseek-ai/schemastery';
24
- // 提示词与写作规则(纯文本常量)单独成模块:本文件是 2400+ 行的 host 全量逻辑,
25
- // 提示词放这里既难找也容易误改。组装逻辑仍在下方(按 stage / kind 选一套)。
26
- import { CHAT_SYSTEM_PROMPT, CODE_PROMPT, DETAIL_PROMPT, TRANSLATION_PROMPT, } from './prompts.js';
27
- // 设置页的字段声明与校验:与 Config 一一对应,GET /settings 直接把 spec 发给客户端,
28
- // 客户端只负责画 —— 避免 UI 的边界和 host 的 clamp 各写一份、时间久了互相漂。
29
- import { buildSettingsPayload, normalizeSettingsPatch, normalizeStoredSettings, PERSISTED_SETTING_KEYS, SETTINGS_KEYS } from './settings.js';
30
- /** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
31
- export const name = '@yfwu2020/dsh-selection-explain';
32
- /**
33
- * 升格时那条「上下文注入」消息的 `source.kind` —— 生产者**自有**的来源标识。
34
- *
35
- * 会话格式 v4 起(本机实测内核 0.2.0-rc.2),消息来源必须是生产者自报的 kind;
36
- * v3 那种 `plugin` 包装(`kind: 'plugin'` 配一个 `plugin` 字段)已经退役,会被
37
- * v4 准入当场拒掉:
38
- * SessionFormatError: format v4 message requires a producer-owned source kind
39
- * 症状极具迷惑性:`↗ 升格` 的 HTTP 请求返回 `ok: true`(那时只写进了内存里的会话面),
40
- * 随后那轮 opener 运行失败、整个 artifact 一个字都没落盘 —— 新会话只剩表头。
41
- * 离线测试也照样全绿(校验发生在真实内核里,不在构建期),所以另配了
42
- * scripts/test-format.mjs 钉住这个 shape。
43
- *
44
- * 第三方插件的规范形态是 `plugin:<插件名>`:内核 v3→v4 迁移把那种 `plugin` 包装
45
- * 重写成 `plugin:<完整插件名>`(`producerKind()` 对不在同名表里的插件就是这么算的),
46
- * 所以这里写的与迁移结果逐字节一致 —— 新旧会话归因相同,且 v3 内核下同样合法
47
- * (直写的 kind 迁移时原样保留)。
48
- */
49
- export const PLUGIN_SOURCE_KIND = `plugin:${name}`;
50
- /** 路由注册与模型调用所需的服务。 */
51
- export const inject = ['webServer', 'llm'];
52
- /** 路由前缀。 */
53
- const API_PREFIX = '/selection-explain/api';
54
- /** 最新一次探针状态(进程集合变了才更新)。 */
55
- const micState = {
56
- available: false,
57
- capturing: false,
58
- processes: [],
59
- /** 不可用时的原因(not-macos / probe-missing / probe-failed / probe-exited)。 */
60
- reason: '',
61
- };
62
- let micChild = null;
63
- /** 只尝试启动一次:失败就认(避免每个请求都去 spawn 一遍)。 */
64
- let micStarted = false;
65
- /** 最近一次有人来问探针的时间(用来判断"没人看了")。 */
66
- let micLastUse = 0;
67
- /** "没人看就停"的检查定时器。 */
68
- let micIdleTimer = null;
69
- /**
70
- * 没人来问之后,探针还留多久(毫秒)。
71
- *
72
- * 为什么不立刻停:页面在"点一下别处"时会瞬间停轮询,立刻杀进程会变成反复 spawn/kill。
73
- * 留一段空转把这种抖动吸收掉;划词期间页面每 200ms 来问一次,探针自然不会到期。
74
- *
75
- * 为什么是 60 秒:让探针"热着"—— 你划词、看两眼、再划下一段,中间往往隔几十秒;
76
- * 探针还在的话,第二次划词是**零启动开销**(冷启动要 ~80ms 才出第一行)。
77
- * 代价是没人用之后进程多留一会儿,而它常驻只占 ~0.8% 单核,值。
78
- * 实际停止发生在超时后的下一次检查,所以真实寿命是 60~65 秒(见 MIC_IDLE_CHECK_MS)。
79
- */
80
- const MIC_IDLE_MS = Number(process.env.DSH_SEL_MIC_IDLE_MS || 60000);
81
- /** 空转检查的间隔(毫秒)。 */
82
- const MIC_IDLE_CHECK_MS = Number(process.env.DSH_SEL_MIC_CHECK_MS || 5000);
83
- /**
84
- * 探针的采样间隔(毫秒)—— 决定它多久发现"麦克风被打开了"。
85
- *
86
- * 为什么是 125:实测常驻成本极低(250ms → 0.6% 单核,150ms → 0.7%),
87
- * 而"按 Fn 到卡片弹出"的延迟里它是主要两项之一(另一项是页面轮询)。
88
- * 调快一倍的代价约 0.1% 单核,换来发现延迟减半 —— 值。
89
- * 原生探针会把区间夹在 50~5000ms 之间(见 native/mic-probe.c)。
90
- */
91
- const MIC_WATCH_MS = Number(process.env.DSH_SEL_MIC_WATCH_MS || 125);
92
- /** 探针路径:lib/native/mic-probe(与编译产物 lib/index.js 同级;测试可注入假的)。 */
93
- const MIC_PROBE = process.env.DSH_SEL_MIC_PROBE || fileURLToPath(new URL('native/mic-probe', import.meta.url));
94
- /**
95
- * 懒启动探针(第一次有人来问才 spawn)。
96
- *
97
- * 为什么不加载就启动:插件装着的绝大多数时间没人划词,没必要常驻一个每秒读几次
98
- * CoreAudio 的进程。第一次 GET /mic 时才起。
99
- * 起过之后**也不是一直留着**:见 armMicIdleCheck —— 没人来问就停,下次再起。
100
- */
101
- function ensureMicProbe() {
102
- if (micStarted)
103
- return;
104
- micStarted = true;
105
- if (process.platform !== 'darwin') {
106
- micState.reason = 'not-macos';
107
- return;
108
- }
109
- if (!existsSync(MIC_PROBE)) {
110
- micState.reason = 'probe-missing';
111
- return;
112
- }
113
- try {
114
- const child = spawn(MIC_PROBE, ['--watch', String(MIC_WATCH_MS)], {
115
- stdio: ['ignore', 'pipe', 'ignore'],
116
- });
117
- micChild = child;
118
- micState.available = true;
119
- micState.reason = '';
120
- if (child.stdout) {
121
- const lines = createInterface({ input: child.stdout });
122
- lines.on('line', (line) => {
123
- try {
124
- const parsed = JSON.parse(line);
125
- micState.capturing = !!parsed.capturing;
126
- micState.processes = Array.isArray(parsed.processes) ? parsed.processes : [];
127
- }
128
- catch {
129
- // 半行 / 坏行:丢掉,下一行会覆盖(探针每行都是完整状态)
130
- }
131
- });
132
- }
133
- child.on('error', () => {
134
- micChild = null;
135
- micState.available = false;
136
- micState.capturing = false;
137
- micState.processes = [];
138
- micState.reason = 'probe-failed';
139
- });
140
- child.on('exit', () => {
141
- micChild = null;
142
- micState.available = false;
143
- micState.capturing = false;
144
- micState.processes = [];
145
- micState.reason = 'probe-exited';
146
- });
147
- }
148
- catch {
149
- micChild = null;
150
- micState.available = false;
151
- micState.reason = 'probe-failed';
152
- }
153
- }
154
- /**
155
- * 排一次"没人看就停"的检查(幂等)。
156
- *
157
- * 页面的轮询只在"有选区 / 卡片开着"时跑,探针跟着它的节奏走就够了 ——
158
- * 划词即启动,选区消失后再空转 MIC_IDLE_MS 就停,下次划词再起。
159
- */
160
- function armMicIdleCheck() {
161
- if (micIdleTimer)
162
- return;
163
- micIdleTimer = setInterval(() => {
164
- if (Date.now() - micLastUse < MIC_IDLE_MS)
165
- return;
166
- stopMicProbe();
167
- }, MIC_IDLE_CHECK_MS);
168
- if (typeof micIdleTimer.unref === 'function')
169
- micIdleTimer.unref();
170
- }
171
- /**
172
- * 停掉探针(没人看了 / 插件卸载)。**下次有人来问会重新启动**。
173
- *
174
- * 注意 available 要跟着回落到 false:探针都停了,再报"正在录"就是过期数据 ——
175
- * 客户端看到 !available 会停掉轮询(不假装),这正是我们要的。
176
- */
177
- function stopMicProbe() {
178
- if (micIdleTimer) {
179
- clearInterval(micIdleTimer);
180
- micIdleTimer = null;
181
- }
182
- // 允许下次重新启动(与"启动失败就认"不同:那是 spawn 失败,不该每个请求都重试)
183
- micStarted = false;
184
- micState.available = false;
185
- micState.capturing = false;
186
- micState.processes = [];
187
- micState.reason = 'idle';
188
- const child = micChild;
189
- micChild = null;
190
- if (!child)
191
- return;
192
- try {
193
- child.kill();
194
- }
195
- catch {
196
- // 已经退了:无所谓
197
- }
198
- }
199
- /**
200
- * GET /selection-explain/api/mic
201
- * → { ok, available, capturing, processes:[{pid,bundleId,name}], reason }
202
- *
203
- * 客户端只关心 `capturing` 和某个进程的 bundleId 是不是目标输入法;
204
- * 一次把所有占用者给全,是因为"同时在录的还有别的 App"很常见(会议软件、录音工具),
205
- * 只报第一个会让特性在那种情况下静默失效。
206
- */
207
- const handleMic = (req, res) => {
208
- // 每次有人来问都续一次命:只要页面在轮询,探针就不会到期
209
- micLastUse = Date.now();
210
- ensureMicProbe();
211
- armMicIdleCheck();
212
- res.writeHead(200, {
213
- 'content-type': 'application/json; charset=utf-8',
214
- 'cache-control': 'no-store',
215
- });
216
- res.end(JSON.stringify({ ok: true, ...micState }));
217
- };
218
- /** 单次请求体上限(字节)。 */
219
- const BODY_LIMIT = 512 * 1024;
220
- /** 配置 schema(缺省值即推荐值)。 */
221
- export const Config = z.object({
222
- provider: z.string().default(''),
223
- model: z.string().default(''),
224
- chatProvider: z.string().default(''),
225
- chatModel: z.string().default(''),
226
- reasoningEffort: z.string().default('high'),
227
- /** 首轮单独一档:默认关闭思考,优先快速给出翻译、解读或注释。 */
228
- translationReasoningEffort: z.string().default('off'),
229
- /**
230
- * 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
231
- * 0 = 关闭历史。只留最近 N 个「划词条目」(每个条目内含它自己的全部追问轮次)。
232
- */
233
- historyMaxEntries: z.number().min(0).max(500).default(20),
234
- /** 采样温度。**默认 -1 = 不传**(交给供应商默认,与主会话一致)。0~2 之间才真的传。 */
235
- temperature: z.number().min(-1).max(2).default(-1),
236
- /**
237
- * 单次调用的输出上限(tokens)。**默认 0 = 不限制** —— 不传这个参数,交给模型/供应商自己的上限。
238
- *
239
- * 为什么不默认给个数:这是**每次调用**的上限,联网检索后的答案经常要 2~4k tokens,
240
- * 之前默认 1200 会让回答"悄悄截断在句子中间"(实测);而给死一个更大的数也一样会在别的场景截断。
241
- * 想限制(控成本/控时延)就填正数,仍然生效。
242
- */
243
- maxTokens: z.number().min(0).max(32000).default(0),
244
- timeoutMs: z.number().min(5000).max(600000).default(300000),
245
- maxSelectionChars: z.number().min(20).max(20000).default(4000),
246
- maxContextChars: z.number().min(200).max(60000).default(12000),
247
- maxRequestsPerMinute: z.number().min(1).max(600).default(40),
248
- sessionContext: z.boolean().default(true),
249
- sessionContextMaxMessages: z.number().min(1).max(200).default(24),
250
- sessionContextFastMessages: z.number().min(1).max(200).default(8),
251
- sessionContextMaxChars: z.number().min(0).max(2000000).default(0),
252
- quoteContextRounds: z.number().min(0).max(10).default(1),
253
- quoteContextMaxCharsPerTurn: z.number().min(200).max(20000).default(2000),
254
- quoteContextMaxChars: z.number().min(400).max(100000).default(6000),
255
- resultCacheTtlMs: z.number().min(0).max(86400000).default(600000),
256
- tools: z.boolean().default(true),
257
- /**
258
- * 工具白名单(逗号分隔)。
259
- *
260
- * 默认改用 **free-search 插件**的工具(`advanced_search` / `platform_search`),不再用 `web_search`:
261
- * 会话里的 `web_search` 是官方 DeepSeek 搜索(复用 @deepseek-ai/dsh-tool-web 的 schema),
262
- * 而新装的 dsh-free-search 只注册了 advanced_search / platform_search / free_search_test ——
263
- * 引擎更多(ddg/bing/searxng/anysearch/exa/tavily/keenable/firecrawl/parallel…)、失败自动换引擎、
264
- * 还支持时间过滤与平台搜索。
265
- */
266
- toolNames: z.string().default('advanced_search,platform_search,read,grep,glob'),
267
- /**
268
- * **备用**工具清单:只在"首选里的联网工具一个都不在"时才补进来。
269
- *
270
- * 场景:没装 free-search 插件(或它没注册工具)时,会话里通常仍有官方 `web_search`;
271
- * 不补的话小窗会变成完全不能联网。装了 free-search 就完全用不到这一项。
272
- */
273
- fallbackToolNames: z.string().default('web_search'),
274
- maxToolRounds: z.number().min(0).max(8).default(2),
275
- toolResultMaxChars: z.number().min(200).max(20000).default(3000),
276
- toolReadRoot: z.string().default(''),
277
- chatReasoningEffort: z.string().default('high'),
278
- /**
279
- * 侧边栏 HTML 预览里的划词桥。关掉就不往预览帧里注入桥脚本,
280
- * 侧边栏网页里选中文字不会再弹「解读」。
281
- */
282
- bridgeSidebarPreview: z.boolean().default(true),
283
- /** 悬浮胶囊开关(小窗设置页可改)。 */
284
- pillEnabled: z.boolean().default(true),
285
- /** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
286
- pillIdleMs: z.number().min(2000).max(600000).default(10000),
287
- /** 空录音是否收起卡片(默认开,见 Config 注释)。 */
288
- voiceCancelOnSilence: z.boolean().default(true),
289
- /** 有字是否自动发送(默认开,见 Config 注释)。 */
290
- voiceAutoSend: z.boolean().default(true),
291
- });
292
- /** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
293
- const SEARCH_TOOL_NAMES = ['advanced_search', 'platform_search', 'web_search'];
294
- /**
295
- * 决定这一轮给模型哪些工具(纯函数,便于单测)。
296
- *
297
- * 规则:
298
- * ① 首选清单里**确实存在**的直接放行;
299
- * ② 如果首选里的**联网工具一个都不在**(例如没装 free-search 插件),
300
- * 就把备用清单里存在的补上 —— 否则小窗会变成完全不能联网;
301
- * ③ 备用只补"联网"这一类,不会顺手把别的东西带进来。
302
- */
303
- function resolveToolNames(preferred, fallback, visible) {
304
- const present = new Set(visible);
305
- const out = preferred.filter((name) => present.has(name));
306
- const hasSearch = preferred.some((name) => SEARCH_TOOL_NAMES.includes(name) && present.has(name));
307
- if (!hasSearch) {
308
- for (const name of fallback) {
309
- if (name && present.has(name) && !out.includes(name))
310
- out.push(name);
311
- }
312
- }
313
- return out;
314
- }
315
- /** 写一条 SSE 事件。 */
316
- function sse(res, payload) {
317
- if (res.writableEnded)
318
- return;
319
- try {
320
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
321
- }
322
- catch {
323
- /* 客户端已断开 */
324
- }
325
- }
326
- /** 返回一个 JSON 响应。 */
327
- /**
328
- * 把模型**内联在正文里**的思考块拆出来。
329
- *
330
- * 为什么要它:有的模型(实测 opencode-go 上的 minimax-m3 偶发)不走 reasoning 通道,
331
- * 而是把整段思考当正文吐出来,形如 `<think>The user wants…</think>`——
332
- * 不处理的话,用户看到的"答案"就是一大段思考(实测报过"输出错乱、把思考也输出了")。
333
- *
334
- * 流式难点是标签可能被切成两块("...<thi" + "nk>..."),所以没找到标签时**留一小截尾巴**再输出。
335
- */
336
- const THINK_TAGS = 'think|thinking|reasoning|analysis|scratchpad';
337
- const THINK_OPEN = new RegExp(`<(${THINK_TAGS})\\s*>`, 'i');
338
- const THINK_CLOSE = new RegExp(`</(${THINK_TAGS})\\s*>`, 'i');
339
- const THINK_TAIL = 24;
340
- function createThinkFilter() {
341
- let buffer = '';
342
- let inside = false;
343
- const drain = (final) => {
344
- let text = '';
345
- let think = '';
346
- for (;;) {
347
- if (inside) {
348
- const close = THINK_CLOSE.exec(buffer);
349
- if (!close) {
350
- if (final) {
351
- think += buffer;
352
- buffer = '';
353
- }
354
- else if (buffer.length > THINK_TAIL) {
355
- think += buffer.slice(0, buffer.length - THINK_TAIL);
356
- buffer = buffer.slice(-THINK_TAIL);
357
- }
358
- break;
359
- }
360
- think += buffer.slice(0, close.index);
361
- buffer = buffer.slice(close.index + close[0].length);
362
- inside = false;
363
- continue;
364
- }
365
- const open = THINK_OPEN.exec(buffer);
366
- if (!open) {
367
- if (final) {
368
- text += buffer;
369
- buffer = '';
370
- }
371
- else if (buffer.length > THINK_TAIL) {
372
- text += buffer.slice(0, buffer.length - THINK_TAIL);
373
- buffer = buffer.slice(-THINK_TAIL);
374
- }
375
- break;
376
- }
377
- text += buffer.slice(0, open.index);
378
- buffer = buffer.slice(open.index + open[0].length);
379
- inside = true;
380
- }
381
- return { text, think };
382
- };
383
- return {
384
- push: (chunk) => {
385
- buffer += chunk;
386
- return drain(false);
387
- },
388
- flush: () => drain(true),
389
- };
390
- }
391
- /**
392
- * 检测"思考被写进正文"的泄漏段。
393
- *
394
- * 实测 omen-alpha 在 `off` 档(上游 none)时不走 reasoning 通道,把整段英文推理当正文吐出来:
395
- * 「The user is asking which model I am. According to my instructions…我是 Omen Alpha。」
396
- * —— 这种文本**不带任何标签**,靠 `<think>` 过滤器抓不到,只能按"开头是自我盘算"来认。
397
- *
398
- * 保守三条件,缺一不裁(宁可留着,也不误删正文):
399
- * ① 逐句扫描,从开头起连续命中"元推理"句式;
400
- * ② 命中段以英文为主(≥80% ASCII 字母);
401
- * ③ 剩下的正文里有中文(说明真正的答案是中文,被裁的不是答案)。
402
- */
403
- const META_SENTENCE = /^\s*(?:The user|The question|The request|The person|According to (?:my|the)|Based on (?:my|the)|Per (?:my|the)|My (?:instructions|system prompt|guidelines)|I (?:should|must|need|will|am asked|always|usually|will just)|So I|Thus I|Therefore I|Instructed to|Now I (?:have|need|know|see)|Let me (?:verify|check|think|see|confirm)|I have (?:the|a|enough)|First,? I|OK,? (?:I|let)|用户(?:问|要求)的?是|根据(?:我的)?(?:指令|系统提示)|我应该|我需要|按照(?:我的)?(?:指令|系统提示))/i;
404
- /**
405
- * 宽松判据:开头是**英文盘算**、后面才是**中文答案**。
406
- * 只用来"记住这个模型在关档会泄漏"(下次自动抬档),**不用来裁剪** ——
407
- * 裁错了会误删正文,而记错了只是下次少一个档位可选,代价不对称。
408
- */
409
- function looksLikeLeakedReasoning(text) {
410
- const body = String(text || '');
411
- if (body.length < 40)
412
- return false;
413
- const cut = body.search(/[\u4e00-\u9fff]/);
414
- if (cut < 30)
415
- return false;
416
- const head = body.slice(0, cut);
417
- const tail = body.slice(cut);
418
- if (!/[\u4e00-\u9fff]/.test(tail))
419
- return false;
420
- const letters = head.replace(/[^A-Za-z]/g, '').length;
421
- return letters / head.length >= 0.5;
422
- }
423
- /**
424
- * 设置页那四个格子(**规范档位**)。
425
- *
426
- * 它们不是发给模型的 id,而是一份"意图词汇":关(尽量不思考)/ 低 / 高 / 最高。
427
- * 各 adapter 的档位词汇表并不统一 —— 本机 74 个模型上就数出七种:
428
- * off>low>high>max / low>high>max / high>max / off>minimal>low>medium>high /
429
- * off>low>medium>high>xhigh>max / low>medium>high>xhigh / minimal>low>medium>high>xhigh /
430
- * max(只有一档)/ 一档都不声明(不支持推理)。
431
- * 落到哪个真实档位由 `mapEffortToModel` 算(纯函数,有单测)。
432
- */
433
- const EFFORT_CANONICAL = ['off', 'low', 'high', 'max'];
434
- /**
435
- * 档位名 → "思考深度"刻度(认 id,也认显示名;比较前会去掉空格/下划线/连字符并转小写)。
436
- *
437
- * 刻度只用来**比较深浅**,绝对数值没有意义。锚点取最常见的五档:off 0 / low 2 /
438
- * medium 4 / high 6 / max 8,其余名字插在它们之间(minimal 在 off 与 low 之间、
439
- * xhigh 在 high 与 max 之间)。认不出的名字不在这张表里 —— 由 `rankEfforts` 按位置插值。
440
- */
441
- const EFFORT_RANK = {
442
- off: 0, none: 0, disabled: 0, no: 0, nocontext: 0,
443
- minimal: 1, minimum: 1, least: 1, tiny: 1,
444
- low: 2, light: 2,
445
- mid: 3, moderate: 3,
446
- medium: 4,
447
- high: 6,
448
- xhigh: 7, veryhigh: 7,
449
- max: 8, maximum: 8, highest: 8,
450
- ultra: 9, extreme: 9,
451
- };
452
- /** 归一化档位名:'X-High' / 'very high' / 'x_high' 都算一个东西。 */
453
- function normEffortName(value) {
454
- return String(value ?? '')
455
- .trim()
456
- .toLowerCase()
457
- .replace(/[\s_-]+/g, '');
458
- }
459
- /**
460
- * 每个声明档位的刻度:认得的名字用表,不认识的按**位置**在已知邻居之间线性插值
461
- * (两头都没有已知锚点时按序号平铺到 0..8)。最后钳成不递减 —— 各 adapter 的声明都是升序,
462
- * 插值不该把这个顺序弄反。
463
- */
464
- function rankEfforts(declared) {
465
- const known = declared.map((item) => {
466
- const byId = EFFORT_RANK[normEffortName(item.id)];
467
- if (byId !== undefined)
468
- return byId;
469
- const byName = EFFORT_RANK[normEffortName(item.name)];
470
- return byName === undefined ? null : byName;
471
- });
472
- const n = declared.length;
473
- const ranks = [];
474
- for (let i = 0; i < n; i += 1) {
475
- const hit = known[i];
476
- if (hit !== null && hit !== undefined) {
477
- ranks.push(hit);
478
- continue;
479
- }
480
- let left = -1;
481
- for (let l = i - 1; l >= 0; l -= 1) {
482
- if (known[l] !== null && known[l] !== undefined) {
483
- left = l;
484
- break;
485
- }
486
- }
487
- let right = -1;
488
- for (let r = i + 1; r < n; r += 1) {
489
- if (known[r] !== null && known[r] !== undefined) {
490
- right = r;
491
- break;
492
- }
493
- }
494
- const leftRank = left >= 0 ? known[left] : null;
495
- const rightRank = right >= 0 ? known[right] : null;
496
- if (leftRank !== null && rightRank !== null) {
497
- ranks.push(leftRank + ((rightRank - leftRank) * (i - left)) / (right - left));
498
- }
499
- else if (leftRank !== null) {
500
- ranks.push(leftRank + (i - left));
501
- }
502
- else if (rightRank !== null) {
503
- ranks.push(rightRank - (right - i));
504
- }
505
- else {
506
- ranks.push(n > 1 ? (8 * i) / (n - 1) : 0);
507
- }
508
- }
509
- for (let i = 1; i < n; i += 1) {
510
- if (ranks[i] < ranks[i - 1])
511
- ranks[i] = ranks[i - 1];
512
- }
513
- return ranks;
514
- }
515
- /**
516
- * 规范四格 → 当前模型**真实声明**的档位(返回 '' 表示这个模型没有可发的档位)。
517
- *
518
- * 规则一条到底(对 `off>low>high>max` 这类 4 档模型恰好是恒等映射):
519
- * · 关 → 声明里**最弱**的那档(模型没有 off 这类档时,就是它"能关到"的最小思考)
520
- * · 最高 → 声明里**最强**的那档
521
- * · 低/高 → 在整条刻度上取 1/3、2/3 处**最近的档**;候选**排除两端**(最弱那档留给「关」、
522
- * 最强那档留给「最高」,四个格子于是各有落点),并保证 关 ≤ 低 ≤ 高 ≤ 最高 单调;
523
- * 同样近时「低」取更浅的、「高」取更深的 —— 低要省、高要深。
524
- *
525
- * ⚠️ 这里**没有**任何按模型写死的表:输入只有"这个模型自己声明的档位列表"。
526
- * 名字认得出来(off/low/high/max、minimal、xhigh…)就用名字的深度刻度;认不出来
527
- * (别的 adapter 可能叫 a/b/c、tier1/tier2,或者干脆是数字)就按**它在声明里的位置**插值 ——
528
- * 所以任何用户换任何模型都能落位,插件作者没见过那个模型也能映射。
529
- * 为什么不让「低/高」也去抢最弱那档:那两个格子表达的是"开着思考、但别太深",
530
- * 落到"关"那一档等于**把用户要的思考关掉**;只有「关」该动那一档。
531
- * 只有一档可发时(如 kimi-k3 只有 max),四格都落到它 —— 模型没有别的选择。
532
- *
533
- * 返回 '' 表示"这个模型没有可发的档位"(声明为空),调用方据此**不带** reasoningEffort。
534
- */
535
- function mapEffortToModel(declared, canonical) {
536
- const list = (declared ?? []).filter((item) => item && typeof item.id === 'string' && item.id.length > 0);
537
- if (list.length === 0)
538
- return '';
539
- if (list.length === 1)
540
- return list[0].id;
541
- const slot = EFFORT_CANONICAL.includes(canonical) ? canonical : 'off';
542
- const ranks = rankEfforts(list);
543
- // 两端:按刻度取最浅/最深;同样深时**以声明顺序为准**(声明在前的那档更弱、在后的更强)。
544
- // 不能只按下标取首尾:adapter 万一声明得不严格升序,按下标就选错了;也不能只按刻度取最大,
545
- // 那样 `…>max>ultra` 这种"更强但刻度相同"的档会被 max 抢走。
546
- let weakest = 0;
547
- let strongest = 0;
548
- for (let i = 1; i < list.length; i += 1) {
549
- if (ranks[i] < ranks[weakest])
550
- weakest = i;
551
- if (ranks[i] >= ranks[strongest])
552
- strongest = i;
553
- }
554
- if (slot === 'max')
555
- return list[strongest].id;
556
- if (slot !== 'low' && slot !== 'high')
557
- return list[weakest].id;
558
- const pool = [];
559
- for (let i = 0; i < list.length; i += 1) {
560
- if (i !== weakest && i !== strongest)
561
- pool.push(i);
562
- }
563
- // 中间两档没人可选时(只有"最弱 + 最强"两档):都落到最强 —— 最弱那档留给「关」
564
- if (pool.length === 0)
565
- return list[strongest].id;
566
- const span = ranks[strongest] - ranks[weakest];
567
- const target = ranks[weakest] + span * (slot === 'low' ? 1 / 3 : 2 / 3);
568
- const preferDeeper = slot === 'high';
569
- let best = pool[0];
570
- let bestDistance = Math.abs(ranks[best] - target);
571
- for (const index of pool) {
572
- const distance = Math.abs(ranks[index] - target);
573
- const closer = distance < bestDistance;
574
- const tie = distance === bestDistance;
575
- if (closer || (tie && (preferDeeper ? ranks[index] > ranks[best] : ranks[index] < ranks[best]))) {
576
- best = index;
577
- bestDistance = distance;
578
- }
579
- }
580
- return list[best].id;
581
- }
582
- /**
583
- * 一个模型的四格映射(设置页给用户看的就是它;请求时也走这份算)。
584
- * 返回空串的格子表示"没有可发的档位"(模型不支持推理时四个格子都是空串)。
585
- */
586
- function effortMapOf(declared) {
587
- const out = {};
588
- for (const slot of EFFORT_CANONICAL)
589
- out[slot] = mapEffortToModel(declared, slot);
590
- return out;
591
- }
592
- /**
593
- * 清单里查不到这个模型时的兜底阶梯。
594
- * 只列**最常见**的四档(与客户端 `FALLBACK_TIERS` 同一份):多出来的 `medium` 很多模型
595
- * 并不声明,而 dsh-llm 对"声明里没有的档"是**直接抛错**(不 clamp)—— 兜底时不该去撞它。
596
- */
597
- const EFFORT_LADDER_FALLBACK = ['off', 'low', 'high', 'max'];
598
- /** 档位的中文名(notice 文案用,与客户端 TIER_LABEL 一致)。 */
599
- const EFFORT_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '最大' };
600
- /**
601
- * 这条上游错误是不是"**我们发的这个档位**不被接受"。
602
- *
603
- * ⚠️ 判据必须窄。免费/预览端点的上游会**偶发**返回
604
- * `Reasoning is mandatory for this endpoint and cannot be disabled.` ——
605
- * 2026-10-09 实测(`opencode-go/step-5-preview-free`,交错发送排除时间漂移):
606
- * · effort=low 成功 5/8;effort=high 成功 5/8 —— **同一档位时好时坏,档位不是变量**;
607
- * · 对照模型 `longcat-2.5-preview-free` 6/6 成功(连 off 都收)。
608
- * 所以只认"明确说这个档位无效"的措辞;"必须开推理"另有一条判据
609
- * (见 looksLikeReasoningRequired,只在**我们发的是关档**时才算数)。
610
- * 把偶发失败当成"档位不支持"会连锁出三个错:给用户一句错诊断、把失败永久记进
611
- * noOffRoutes(以后再也不发这个档)、清掉用户选的档位。
612
- */
613
- function looksLikeUnsupportedTier(message) {
614
- const tier = '(?:reasoning|effort|thinking|推理|思考|档位?)';
615
- const bad = '(?:unsupported|not\\s+support|invalid|unrecognized|不支持|无效|不认识)';
616
- return (new RegExp(`${bad}[^。\\n]{0,24}${tier}`, 'i').test(message) ||
617
- new RegExp(`${tier}[^。\\n]{0,24}${bad}`, 'i').test(message));
618
- }
619
- /** 上游要求"必须开推理"——只有在**我们这次把推理关掉**(发的 off)时,这句才是冲我们来的。 */
620
- function looksLikeReasoningRequired(message) {
621
- return (/(?:reasoning|thinking|推理|思考)/i.test(message) &&
622
- /(?:mandatory|required|must|cannot\s+be\s+disabled|必须|不能(?:被)?关闭|不可关闭)/i.test(message));
623
- }
624
- /**
625
- * 换一个"没试过的"档位(重试用)。
626
- *
627
- * 为什么**绝不能省略档位**(老代码就是省略):省略 = 请求里不带 reasoning,
628
- * 对"必须开推理"的端点等于把推理关掉 —— 实测 20 次这样的重试 **0 次成功**,
629
- * 而同一档位首发成功率约 60%。省略比"发一个可能不被接受的档"更糟。
630
- *
631
- * 顺序:模型自己声明的 defaultEffort → 沿档位阶梯往上走一格(离"关"越远越安全)。
632
- * 返回空串表示"没档位可换了",调用方就不重试。
633
- */
634
- function pickNextEffort(declared, defaultEffort, rejected, tried) {
635
- const usable = (id) => !!id && id !== rejected && !tried.has(id);
636
- if (usable(defaultEffort))
637
- return defaultEffort;
638
- // 用**模型自己的声明顺序**(各 adapter 都是从最省到最深),"往上走一格"才落在它真实的下一档上。
639
- // 老写法是"我方五档阶梯 ∩ 声明",看不见 minimal / xhigh / none 这类 id —— 而按刻度映射之后,
640
- // 被拒的那个很可能正是这类 id(indexOf 得到 -1 → 退回最弱的那档,方向正好反了)。
641
- const order = declared.length > 0 ? declared : EFFORT_LADDER_FALLBACK;
642
- const start = order.indexOf(rejected);
643
- // ① 先往上走:离「关」越远越安全(不吃 off 的模型通常都吃更高的档)
644
- for (let i = (start < 0 ? 0 : start + 1); i < order.length; i += 1) {
645
- const id = order[i];
646
- if (usable(id))
647
- return id;
648
- }
649
- // ② 再按"通常最稳"的顺序退(模型拒的是 max 时往上没得走,该退到 high,而不是退回 off ——
650
- // off 恰恰是最容易被拒的那一档)
651
- for (const id of ['high', 'medium', 'low', 'max', 'off']) {
652
- if (order.includes(id) && usable(id))
653
- return id;
654
- }
655
- // ③ 兜底:声明里剩下的
656
- for (const id of order)
657
- if (usable(id))
658
- return id;
659
- return '';
660
- }
661
- function splitLeakedReasoning(text) {
662
- const body = String(text || '');
663
- if (body.length < 40)
664
- return { prefix: '', rest: body };
665
- // 注意:不能用 split(/(?<=[.。!!??])\s*/) —— 它会把句末空白吃掉,
666
- // 于是拼回来的 prefix 与真正流出去的正文差一个空格,客户端 indexOf 匹配不上(实测踩过)。
667
- // 这里保留分隔符本身,保证 parts.join('') === body。
668
- const parts = body.match(/[^.。!!??]*[.。!!??]\s*|[^.。!!??]+$/g) ?? [body];
669
- // 从句首开始,连续吃"不含中文的句子"作为泄漏段。
670
- // 为什么不是"必须命中元推理句式":实测泄漏段里会夹着 "Previously I said something incorrect … — that was wrong."
671
- // 这种自述句,逐句匹配元句式会在这里断掉、只裁掉前半段。结构判据(英文盘算 + 中文答案)更稳。
672
- // 但仍然要求:整段里**至少有一句**命中元推理句式,避免把"英文开头的正常回答"误裁。
673
- let index = 0;
674
- let ascii = 0;
675
- let metaHit = false;
676
- while (index < parts.length && !/[\u4e00-\u9fff]/.test(parts[index])) {
677
- if (META_SENTENCE.test(parts[index]))
678
- metaHit = true;
679
- ascii += (parts[index].match(/[A-Za-z]/g) || []).length;
680
- index += 1;
681
- }
682
- if (index === 0 || !metaHit)
683
- return { prefix: '', rest: body };
684
- const head = parts.slice(0, index).join('');
685
- const tail = parts.slice(index).join('');
686
- if (!tail.trim())
687
- return { prefix: '', rest: body }; // 整篇都是盘算:不裁(可能是模型只说了这些)
688
- const letters = head.replace(/[^A-Za-z]/g, '').length;
689
- if (head.length > 0 && letters / head.length < 0.5)
690
- return { prefix: '', rest: body };
691
- if (!/[\u4e00-\u9fff]/.test(tail))
692
- return { prefix: '', rest: body };
693
- return { prefix: head, rest: tail };
694
- }
695
- function sendJson(res, status, body) {
696
- const text = JSON.stringify(body);
697
- res.writeHead(status, {
698
- 'content-type': 'application/json; charset=utf-8',
699
- 'content-length': Buffer.byteLength(text),
700
- 'cache-control': 'no-store',
701
- });
702
- res.end(text);
703
- }
704
- /** 读取请求体(带长度上限)。 */
705
- function readBody(req, limit) {
706
- return new Promise((resolve, reject) => {
707
- let size = 0;
708
- const chunks = [];
709
- req.on('data', (chunk) => {
710
- size += chunk.length;
711
- if (size > limit) {
712
- reject(new Error('请求体过大'));
713
- req.destroy();
714
- return;
715
- }
716
- chunks.push(chunk);
717
- });
718
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
719
- req.on('error', reject);
720
- });
721
- }
722
- /** 截断到指定字符数(保留省略标记)。 */
723
- function clampText(raw, max) {
724
- const text = typeof raw === 'string' ? raw.trim() : '';
725
- return text.length > max ? `${text.slice(0, max)}\n…(已截断)` : text;
726
- }
727
- // ────────────────────────────── 语音输入(小窗麦克风) ──────────────────────────────
728
- //
729
- // 小窗的追问输入框加一个麦克风:录一段话 → 转成文字**插进输入框**(不是直接发送,
730
- // 识别错了还能改)。转写这条链路在 host 这一侧收口:
731
- //
732
- // GET ${API_PREFIX}/speech 识别服务目录 + 就绪状态(客户端据此决定麦克风能不能点)
733
- // POST ${API_PREFIX}/speech/transcribe 一段 16kHz 单声道 PCM16 WAV(base64)→ 文字
734
- // POST ${API_PREFIX}/speech/prepare 显式准备本地识别模型(首次要下载,必须用户点头)
735
- //
736
- // 为什么不让浏览器自己去连识别服务:音频、provider 选择、模型路径全部留在 host,
737
- // 客户端只拿到"一段话的文字";而且**音频不落盘、不进会话**(转写完即丢)。
738
- // 识别服务(@deepseek-ai/dsh-experimental-speech-to-text)是实验性的、可以不装:
739
- // 拿不到该服务就整条链路优雅降级(目录接口回 available:false,麦克风点了只给一句解释)。
740
- /** 单次录音时长上限(秒):到了就自动收尾去识别,不让用户对着麦克风发呆。 */
741
- const SPEECH_MAX_SECONDS = 60;
742
- /** 单条音频的字节上限(≈ 2 分钟 16kHz 单声道 PCM16);与识别服务默认的 4MB 对齐。 */
743
- const SPEECH_MAX_BYTES = 4 * 1024 * 1024;
744
- /** 识别请求体上限:base64 会膨胀 4/3,再留点余量给 JSON 外壳。 */
745
- const SPEECH_BODY_LIMIT = Math.ceil(SPEECH_MAX_BYTES / 3) * 4 + 4096;
746
- /** 单次识别的等待上限(毫秒):真机上 60 秒音频几秒内就回来了,这是兜底。 */
747
- const SPEECH_TIMEOUT_MS = 120_000;
748
- /**
749
- * 校验客户端送上来的音频是不是**规范**的 16kHz 单声道 PCM16 WAV。
750
- *
751
- * 为什么抠得这么死:这个格式是浏览器端编码器(MediaRecorder → decodeAudioData →
752
- * OfflineAudioContext 重采样 → 手写 WAV 头)唯一会产出的形状。逐字段对齐之后,
753
- * "音频不对"就只剩两种可能——客户端版本对不上、或者有人手工构造了这个请求。
754
- * 光看二进制长度不算数:头里写的采样率/声道/位深必须是那一套,否则识别服务会按
755
- * 错误的采样率解释这段音频(听起来像快放/慢放),识别结果自然全错。
756
- *
757
- * @param audio 完整音频字节
758
- * @param maxSeconds 允许的最长时长
759
- * @returns 时长(秒)
760
- */
761
- export function validateWave(audio, maxSeconds) {
762
- if (audio.length < 46 ||
763
- audio.toString('ascii', 0, 4) !== 'RIFF' ||
764
- audio.toString('ascii', 8, 12) !== 'WAVE' ||
765
- audio.toString('ascii', 12, 16) !== 'fmt ' ||
766
- audio.readUInt32LE(16) !== 16 ||
767
- audio.readUInt16LE(20) !== 1 || // PCM
768
- audio.readUInt16LE(22) !== 1 || // 单声道
769
- audio.readUInt32LE(24) !== 16000 || // 16kHz
770
- audio.readUInt32LE(28) !== 32000 || // 字节率 = 16000 × 1ch × 2B
771
- audio.readUInt16LE(32) !== 2 || // 块对齐
772
- audio.readUInt16LE(34) !== 16 || // 16 bit
773
- audio.toString('ascii', 36, 40) !== 'data' ||
774
- audio.readUInt32LE(4) !== audio.length - 8 ||
775
- audio.readUInt32LE(40) !== audio.length - 44 ||
776
- (audio.length - 44) % 2 !== 0) {
777
- throw new Error('音频格式不是 16kHz 单声道 PCM16 WAV');
778
- }
779
- const seconds = (audio.length - 44) / 32000;
780
- if (seconds <= 0)
781
- throw new Error('录音是空的');
782
- if (seconds > maxSeconds)
783
- throw new Error(`录音超过 ${maxSeconds} 秒`);
784
- return seconds;
785
- }
786
- /**
787
- * 「网页模式」的设计规范:从插件自带的 `skills/web-design/SKILL.md` 读(按 mtime 缓存)。
788
- *
789
- * 做成文件而不是写死在代码里:改规范不用重新构建,用户也能自己替换成喜欢的版本。
790
- * 读不到就退回一段精简规则(宁可少讲,也不能让请求挂掉)。
791
- */
792
- let designSkillCache = { at: 0, path: '', text: '' };
793
- const DESIGN_SKILL_FALLBACK = [
794
- '单列、宽度 100%、不要横向滚动;不引外部资源;一套主题(别深浅混用)。',
795
- '主标题 20–22px、正文 14px、行高 1.6–1.75;一个主色 + 中性灰阶。',
796
- '只在内容真是序列时才编号;避免"什么内容都套同一张圆角卡片"这类模板感。',
797
- ].join('\n');
798
- /** 模块级读文件失败时的告警(host 侧 logger 在 apply 里拿不到,走 console)。 */
799
- function ctxLoggerWarn(message) {
800
- try {
801
- console.warn(`[dsh-selection-explain] ${message}`);
802
- }
803
- catch {
804
- /* noop */
805
- }
806
- }
807
- async function loadDesignSkill() {
808
- const path = fileURLToPath(new URL('../skills/web-design/SKILL.md', import.meta.url));
809
- try {
810
- const info = await stat(path);
811
- if (designSkillCache.path === path && designSkillCache.at === info.mtimeMs && designSkillCache.text) {
812
- return designSkillCache.text;
813
- }
814
- const raw = await readFile(path, 'utf8');
815
- // 去掉 frontmatter,只把规则喂给模型
816
- const body = raw.replace(/^---[\s\S]*?---\s*/, '').trim();
817
- designSkillCache = { at: info.mtimeMs, path, text: body };
818
- return body;
819
- }
820
- catch (error) {
821
- ctxLoggerWarn(`读取设计规范失败(${path}):${String(error?.message ?? error)}`);
822
- return DESIGN_SKILL_FALLBACK;
823
- }
824
- }
825
- /**
826
- * 粗略判断"是不是公网地址",口径与 dsh-web-fetch-http 的 SSRF 防护一致(非 unicast 一律拒绝)。
827
- *
828
- * 为什么要这个:本机若跑着 TUN/fake-IP 代理(DNS 服务器是 198.18.0.2 这类),所有域名都会
829
- * 解析进 198.18.0.0/15 保留段 → web_fetch 每次都秒失败("resolves to a non-public IP address"),
830
- * 而那个防护**没有配置开关**。与其让模型白跑一轮,不如探测出来直接不给它这个工具。
831
- */
832
- function isPublicAddress(address) {
833
- const value = String(address || '').trim();
834
- if (!value)
835
- return false;
836
- if (value.indexOf(':') >= 0) {
837
- const lower = value.toLowerCase();
838
- if (lower === '::1' || lower === '::')
839
- return false;
840
- if (/^f[cd][0-9a-f]{2}:/.test(lower))
841
- return false;
842
- if (/^fe[89ab][0-9a-f]:/.test(lower))
843
- return false;
844
- return true;
845
- }
846
- const parts = value.split('.').map((part) => Number(part));
847
- if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
848
- return false;
849
- const a = parts[0];
850
- const b = parts[1];
851
- const c = parts[2];
852
- if (a === 0 || a === 10 || a === 127)
853
- return false;
854
- if (a === 100 && b >= 64 && b <= 127)
855
- return false;
856
- if (a === 169 && b === 254)
857
- return false;
858
- if (a === 172 && b >= 16 && b <= 31)
859
- return false;
860
- if (a === 192 && (b === 168 || b === 0))
861
- return false;
862
- if (a === 198 && (b === 18 || b === 19))
863
- return false; // 198.18.0.0/15 基准测试段 ← fake-IP 落这里
864
- if (a === 198 && b === 51 && c === 100)
865
- return false;
866
- if (a === 203 && b === 0 && c === 113)
867
- return false;
868
- if (a >= 224)
869
- return false;
870
- return true;
871
- }
872
- /** 本机能否真正抓取网页(结果缓存 5 分钟,探测失败则不动它)。 */
873
- let fetchProbe = { at: 0, usable: true, sample: '' };
874
- async function webFetchUsable() {
875
- if (Date.now() - fetchProbe.at < 300_000)
876
- return fetchProbe;
877
- try {
878
- const addresses = await lookup('example.com', { all: true });
879
- const usable = addresses.length > 0 && addresses.every((entry) => isPublicAddress(entry.address));
880
- fetchProbe = { at: Date.now(), usable, sample: addresses.map((entry) => entry.address).join(', ') };
881
- }
882
- catch {
883
- fetchProbe = { at: Date.now(), usable: true, sample: '' };
884
- }
885
- return fetchProbe;
886
- }
887
- /**
888
- * 路径是否落在允许的根目录里(root 为空 = 不设限)。
889
- *
890
- * 为什么需要它:read/grep/glob 能读**用户有权读的任何文件**(`~/.ssh/id_rsa`、`.env`、
891
- * `~/.dsh/settings.yaml`……),而划词的触发源(选中文字/上下文/搜索结果)可能是别人写的。
892
- * 这里只做词法判断(`resolve` 不跟随符号链接),挡住"顺着一段被注入的文字去读项目外的机密"。
893
- */
894
- export function isInsideRoot(root, target) {
895
- if (!root)
896
- return true;
897
- const raw = String(target ?? '').trim();
898
- if (!raw)
899
- return true;
900
- // `~` 一律拒:要么指向家目录(多数情况下在项目外),要么后端根本不展开
901
- if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\'))
902
- return false;
903
- const base = resolve(root);
904
- const full = resolve(base, raw);
905
- return full === base || full.startsWith(base.endsWith(sep) ? base : base + sep);
906
- }
907
- /** 文件类工具:这些工具的参数里有 path,需要过边界检查。 */
908
- const FS_TOOL_NAMES = ['read', 'grep', 'glob'];
909
- /** 工具调用的人类可读描述(给前端状态栏用)。 */
910
- function describeToolCall(name, args) {
911
- const record = (args ?? {});
912
- // command 也要认:bash 这类工具没有 query/url,不认的话摘要里只剩「bash」两个字
913
- const raw = record.query ??
914
- record.queries ??
915
- record.q ??
916
- record.url ??
917
- record.prompt ??
918
- record.command ??
919
- record.file_path ??
920
- record.path ??
921
- record.pattern;
922
- const query = Array.isArray(raw) ? raw[0] : raw;
923
- return typeof query === 'string' ? query.slice(0, 120) : name;
924
- }
925
- /** 从工具结果里抽出:文本长度 / 预览 / 链接(前端只用它拼追问摘要,**不渲染**)。 */
926
- function digestToolResult(content) {
927
- const text = content
928
- .map((block) => (block.type === 'text' ? block.text : ''))
929
- .join('\n')
930
- .trim();
931
- const urls = [];
932
- const matches = text.match(/https?:\/\/[^\s<>"')\]]+/g) ?? [];
933
- for (const raw of matches) {
934
- const url = raw.replace(/[.,;:)\]}]+$/, '');
935
- if (!urls.includes(url))
936
- urls.push(url);
937
- if (urls.length >= 5)
938
- break;
939
- }
940
- return { chars: text.length, preview: text.slice(0, 600), urls };
941
- }
942
- /** 执行一个工具并把结果压成模型可读的内容块(带长度上限)。 */
943
- async function runTool(ctx, name, args, signal, maxChars, agent) {
944
- const registry = ctx.get('tools');
945
- if (typeof registry?.execute !== 'function') {
946
- return { content: [{ type: 'text', text: '(工具不可用)' }], isError: true };
947
- }
948
- try {
949
- const result = await registry.execute({
950
- callId: `sel-tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
951
- name,
952
- arguments: args,
953
- signal,
954
- ...(agent ? { agent } : {}),
955
- });
956
- const blocks = Array.isArray(result.content) ? result.content : [];
957
- let used = 0;
958
- const clamped = [];
959
- for (const block of blocks) {
960
- if (block.type !== 'text')
961
- continue;
962
- const remain = maxChars - used;
963
- if (remain <= 0)
964
- break;
965
- const text = block.text.length > remain ? `${block.text.slice(0, remain)}
966
- …(已截断)` : block.text;
967
- used += text.length;
968
- clamped.push({ type: 'text', text });
969
- }
970
- if (clamped.length === 0)
971
- clamped.push({ type: 'text', text: '(工具没有返回文本内容)' });
972
- return { content: clamped, isError: result.isError === true };
973
- }
974
- catch (error) {
975
- return { content: [{ type: 'text', text: `工具执行失败:${String(error?.message ?? error)}` }], isError: true };
976
- }
977
- }
978
- /**
979
- * `createSystemMessage` 的**跨版本兼容调用**。
980
- *
981
- * 这个函数的签名在两个版本间不一样,而两份类型都得能编译通过:
982
- * · **0.1.5-rc.3**(npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的就是它,CI 的构建校验用的这份):
983
- * `createSystemMessage(text, plugin)` —— 两个都必填,少传一个直接 TS2554(发布被卡过一次);
984
- * · **0.1.7-rc.x**(本机/新运行时):`createSystemMessage(text)` —— 第二参数是历史遗留,传了会被忽略。
985
- *
986
- * 所以这里把它当"接受可选第二参数"的函数来调:两个版本都编译得过,运行时也都正确
987
- * (旧版正好拿到它想要的"组装这段提示词的插件名",新版直接忽略)。
988
- */
989
- const systemMessageOf = createSystemMessage;
990
- /** 请求里带的会话 id(空串 = 客户端没取到)。 */
991
- function sessionIdOf(body) {
992
- return typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
993
- }
994
- /** 空白归一化(用于在会话记录里定位选中文字)。 */
995
- function normalizeSpace(text) {
996
- return text.replace(/\s+/g, ' ').trim();
997
- }
998
- /** 从消息 content 里取纯文本(只取 text 块;工具调用/结果不进来,避免噪音挤占预算)。 */
999
- function messageText(content) {
1000
- if (!Array.isArray(content))
1001
- return '';
1002
- const parts = [];
1003
- for (const block of content) {
1004
- if (!block || typeof block !== 'object')
1005
- continue;
1006
- const typed = block;
1007
- if (typed.type === 'text' && typeof typed.text === 'string')
1008
- parts.push(typed.text);
1009
- }
1010
- return parts.join('\n').trim();
1011
- }
1012
- /**
1013
- * 把会话的当前模型界面整理成「会话背景」文本。
1014
- *
1015
- * 只保留**真实对话**:用户真正发的话(source.kind === 'user')与助手回复的正文。
1016
- * 三类内容一律剔除,否则会把有效窗口挤没(实测:430 条 surface 事件里绝大多数是
1017
- * 工具调用/结果,且 harness 注入的 system-reminder 单条可达 1.2 万字):
1018
- * - tool/result 与纯工具调用的助手步骤(无正文)
1019
- * - system/message(系统提示词)
1020
- * - source.kind !== 'user' 的用户角色消息(运行环境快照、skill 目录等 harness 注入)
1021
- */
1022
- export function transcriptOf(events, options) {
1023
- const entries = [];
1024
- for (const event of events) {
1025
- if (!event || typeof event !== 'object')
1026
- continue;
1027
- const typed = event;
1028
- const data = (typed.data ?? {});
1029
- let role = '';
1030
- let content;
1031
- if (typed.type === 'user/message') {
1032
- if (data.source && data.source.kind !== 'user')
1033
- continue; // harness 注入的上下文
1034
- role = '用户';
1035
- content = data.content;
1036
- }
1037
- else if (typed.type === 'assistant/message') {
1038
- role = '助手';
1039
- content = data.message?.content;
1040
- }
1041
- else {
1042
- continue;
1043
- }
1044
- const text = messageText(content);
1045
- if (!text)
1046
- continue;
1047
- entries.push({ role, text, marked: false });
1048
- }
1049
- const needle = normalizeSpace(options.marker);
1050
- let marked = false;
1051
- if (needle.length >= 2) {
1052
- for (const entry of entries) {
1053
- if (normalizeSpace(entry.text).includes(needle)) {
1054
- entry.marked = true;
1055
- marked = true;
1056
- }
1057
- }
1058
- }
1059
- // 窗口 = 选中文字所在的那条消息 **向上** 数 maxMessages 条(含它自己),
1060
- // 之后的对话不进背景;找不到选中位置时退回「最近 maxMessages 条」。
1061
- // 默认不截断(maxChars = 0):消息整条进背景,不做单条/总量裁剪。
1062
- let anchorIndex = -1;
1063
- if (marked) {
1064
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1065
- if (entries[i]?.marked) {
1066
- anchorIndex = i;
1067
- break;
1068
- }
1069
- }
1070
- }
1071
- const endIndex = anchorIndex >= 0 ? anchorIndex : entries.length - 1;
1072
- const startIndex = Math.max(0, endIndex - options.maxMessages + 1);
1073
- const picked = entries.slice(startIndex, endIndex + 1).map((entry) => ({ ...entry }));
1074
- // 可选总量上限(配置里显式设 >0 时才生效):超了就丢更早的消息
1075
- if (options.maxChars > 0) {
1076
- let used = picked.reduce((sum, entry) => sum + entry.text.length, 0);
1077
- while (picked.length > 1 && used > options.maxChars) {
1078
- const dropped = picked.shift();
1079
- used -= dropped ? dropped.text.length : 0;
1080
- }
1081
- for (const entry of picked) {
1082
- if (entry.text.length > options.maxChars)
1083
- entry.text = `${entry.text.slice(0, options.maxChars)}…`;
1084
- }
1085
- }
1086
- if (picked.length === 0)
1087
- return { transcript: '', marked: false };
1088
- const omitted = Math.max(0, entries.length - picked.length);
1089
- if (omitted > 0)
1090
- picked.unshift({ role: '提示', text: `(选中位置之前还有 ${omitted} 条更早的消息,未包含)`, marked: false });
1091
- const transcript = picked.map((entry) => `${entry.marked ? '▶ ' : ' '}${entry.role}:${entry.text}`).join('\n');
1092
- return { transcript, marked: picked.some((entry) => entry.marked) };
1093
- }
1094
- /** 在文本里找到引用文字并套上【】(直接找不到就按"空白等价"再找一次)。 */
1095
- function markQuoteInText(text, marker) {
1096
- if (!text || !marker)
1097
- return text;
1098
- const at = text.indexOf(marker);
1099
- if (at >= 0)
1100
- return `${text.slice(0, at)}【${marker}】${text.slice(at + marker.length)}`;
1101
- // 归一化后匹配:把引用按空白切开,逐段允许任意空白(选中文字常与消息原文差在换行/缩进)
1102
- const tokens = marker.split(/\s+/).filter(Boolean).map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
1103
- if (tokens.length === 0)
1104
- return text;
1105
- try {
1106
- const hit = new RegExp(tokens.join('\\s+')).exec(text);
1107
- if (hit && typeof hit.index === 'number') {
1108
- return `${text.slice(0, hit.index)}【${hit[0]}】${text.slice(hit.index + hit[0].length)}`;
1109
- }
1110
- }
1111
- catch {
1112
- /* 正则构造失败(极端输入):不标记,客户端会把原文另起一段补上 */
1113
- }
1114
- return text;
1115
- }
1116
- /** 围绕引用截断长消息(引用本身必须留下;找不到引用就从头截)。 */
1117
- function clampAroundQuote(text, marker, max) {
1118
- if (text.length <= max)
1119
- return text;
1120
- const at = marker ? text.indexOf(marker) : -1;
1121
- if (at < 0)
1122
- return `${text.slice(0, max)}…(已截断)`;
1123
- const half = Math.max(0, Math.floor((max - marker.length) / 2));
1124
- const from = Math.max(0, at - half);
1125
- const to = Math.min(text.length, at + marker.length + half);
1126
- return `${from > 0 ? '…' : ''}${text.slice(from, to)}${to < text.length ? '…' : ''}`;
1127
- }
1128
- /**
1129
- * **引用上下文**:引用文字所在的那一组对话 ± N 组(一组 = 一条用户消息 + 它的助手回复)。
1130
- *
1131
- * 与 `transcriptOf`(解读用的会话背景)的区别,两条都是为引用量身定的:
1132
- * ① 窗口是**锚点两侧** —— 引用常常是"上面那句"和"下面那句"的对照,只有上文不够;
1133
- * ② 按**轮**取整 —— 上下各至少一整组(用户消息 + AI 回复),不会切在半个回合上。
1134
- * 噪音过滤与 `transcriptOf` 完全一致(工具调用/结果、系统提示、harness 注入一律不进)。
1135
- */
1136
- export function quoteContextOf(events, marker, options = {}) {
1137
- const roundsAround = Math.max(0, options.roundsAround ?? 1);
1138
- const maxCharsPerTurn = Math.max(200, options.maxCharsPerTurn ?? 2000);
1139
- const maxChars = Math.max(400, options.maxChars ?? 6000);
1140
- const entries = [];
1141
- for (const event of events) {
1142
- if (!event || typeof event !== 'object')
1143
- continue;
1144
- const typed = event;
1145
- const data = (typed.data ?? {});
1146
- let role = '';
1147
- let content;
1148
- if (typed.type === 'user/message') {
1149
- if (data.source && data.source.kind !== 'user')
1150
- continue;
1151
- role = '用户';
1152
- content = data.content;
1153
- }
1154
- else if (typed.type === 'assistant/message') {
1155
- role = '助手';
1156
- content = data.message?.content;
1157
- }
1158
- else {
1159
- continue;
1160
- }
1161
- const text = messageText(content);
1162
- if (!text)
1163
- continue;
1164
- entries.push({ role, text, marked: false });
1165
- }
1166
- if (entries.length === 0)
1167
- return { transcript: '', matched: false, rounds: 0 };
1168
- const needle = normalizeSpace(marker);
1169
- let anchor = -1;
1170
- if (needle.length >= 2) {
1171
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1172
- if (normalizeSpace(entries[i]?.text ?? '').includes(needle)) {
1173
- anchor = i;
1174
- break;
1175
- }
1176
- }
1177
- }
1178
- if (anchor < 0)
1179
- return { transcript: '', matched: false, rounds: 0 };
1180
- // 按"轮"分组:一条用户消息开一轮(它之前的助手消息归上一轮)
1181
- const roundOf = [];
1182
- let round = -1;
1183
- for (const entry of entries) {
1184
- if (entry.role === '用户' || round < 0)
1185
- round += 1;
1186
- roundOf.push(round);
1187
- }
1188
- const anchorRound = roundOf[anchor] ?? 0;
1189
- const fromRound = Math.max(0, anchorRound - roundsAround);
1190
- const toRound = Math.min(round, anchorRound + roundsAround);
1191
- const picked = [];
1192
- for (let i = 0; i < entries.length; i += 1) {
1193
- const r = roundOf[i] ?? 0;
1194
- if (r < fromRound || r > toRound)
1195
- continue;
1196
- const entry = entries[i];
1197
- const isAnchor = i === anchor;
1198
- const raw = isAnchor ? markQuoteInText(entry.text, marker) : entry.text;
1199
- picked.push({
1200
- role: entry.role,
1201
- text: clampAroundQuote(raw, marker, maxCharsPerTurn),
1202
- marked: isAnchor,
1203
- });
1204
- }
1205
- // 总量上限:超了先丢最早的几组,再丢引用之后的几组(**引用所在那条永远保留**)
1206
- let used = picked.reduce((sum, entry) => sum + entry.text.length, 0);
1207
- let droppedBefore = 0;
1208
- let droppedAfter = 0;
1209
- while (picked.length > 1 && used > maxChars && picked[0] && !picked[0].marked) {
1210
- const first = picked.shift();
1211
- droppedBefore += 1;
1212
- used -= first ? first.text.length : 0;
1213
- }
1214
- while (picked.length > 1 && used > maxChars && picked[picked.length - 1] && !picked[picked.length - 1]?.marked) {
1215
- const last = picked.pop();
1216
- droppedAfter += 1;
1217
- used -= last ? last.text.length : 0;
1218
- }
1219
- if (droppedBefore > 0)
1220
- picked.unshift({ role: '提示', text: `(引用之前还有 ${droppedBefore} 条消息,未包含)`, marked: false });
1221
- if (droppedAfter > 0)
1222
- picked.push({ role: '提示', text: `(引用之后还有 ${droppedAfter} 条消息,未包含)`, marked: false });
1223
- const transcript = picked.map((entry) => `${entry.role}:${entry.text}`).join('\n');
1224
- return { transcript, matched: true, rounds: toRound - fromRound + 1 };
1225
- }
1226
- /** 注册路由、解析模型路由、转发 LLM 流。 */
1227
- export function apply(ctx, rawConfig) {
1228
- const config = {
1229
- provider: rawConfig?.provider ?? '',
1230
- model: rawConfig?.model ?? '',
1231
- chatProvider: rawConfig?.chatProvider ?? '',
1232
- chatModel: rawConfig?.chatModel ?? '',
1233
- reasoningEffort: rawConfig?.reasoningEffort ?? 'high',
1234
- translationReasoningEffort: rawConfig?.translationReasoningEffort ?? 'off',
1235
- historyMaxEntries: rawConfig?.historyMaxEntries ?? 20,
1236
- temperature: rawConfig?.temperature ?? -1,
1237
- maxTokens: rawConfig?.maxTokens ?? 0,
1238
- timeoutMs: rawConfig?.timeoutMs ?? 300000,
1239
- maxSelectionChars: rawConfig?.maxSelectionChars ?? 4000,
1240
- maxContextChars: rawConfig?.maxContextChars ?? 12000,
1241
- maxRequestsPerMinute: rawConfig?.maxRequestsPerMinute ?? 40,
1242
- sessionContext: rawConfig?.sessionContext ?? true,
1243
- sessionContextMaxMessages: rawConfig?.sessionContextMaxMessages ?? 24,
1244
- sessionContextFastMessages: rawConfig?.sessionContextFastMessages ?? 8,
1245
- sessionContextMaxChars: rawConfig?.sessionContextMaxChars ?? 0,
1246
- quoteContextRounds: rawConfig?.quoteContextRounds ?? 1,
1247
- quoteContextMaxCharsPerTurn: rawConfig?.quoteContextMaxCharsPerTurn ?? 2000,
1248
- quoteContextMaxChars: rawConfig?.quoteContextMaxChars ?? 6000,
1249
- resultCacheTtlMs: rawConfig?.resultCacheTtlMs ?? 600000,
1250
- tools: rawConfig?.tools ?? true,
1251
- toolNames: rawConfig?.toolNames ?? 'advanced_search,platform_search,read,grep,glob',
1252
- fallbackToolNames: rawConfig?.fallbackToolNames ?? 'web_search',
1253
- maxToolRounds: rawConfig?.maxToolRounds ?? 2,
1254
- toolResultMaxChars: rawConfig?.toolResultMaxChars ?? 3000,
1255
- toolReadRoot: rawConfig?.toolReadRoot ?? '',
1256
- chatReasoningEffort: rawConfig?.chatReasoningEffort ?? 'high',
1257
- bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
1258
- pillEnabled: rawConfig?.pillEnabled ?? true,
1259
- pillIdleMs: rawConfig?.pillIdleMs ?? 10000,
1260
- voiceCancelOnSilence: rawConfig?.voiceCancelOnSilence ?? true,
1261
- voiceAutoSend: rawConfig?.voiceAutoSend ?? true,
1262
- };
1263
- /**
1264
- * 出厂默认值(**不含**用户在设置页改过的部分)。
1265
- *
1266
- * 为什么单独留一份:设置页的「恢复默认」要能把值写回这里,
1267
- * 而"这一项改过没有"也要拿它对比 —— 只有一个 config 的话,改完就再也说不出原值是什么。
1268
- * 注意它取的是 rawConfig(cordis.patch.yml 里的配置),所以 yml 里写过的值也算"默认":
1269
- * 用户在设置页恢复默认,应该回到 yml 写的那个数,而不是硬编码的出厂数。
1270
- */
1271
- const configDefaults = { ...config };
1272
- /**
1273
- * 设置页写入的覆盖值(**只存改过的键**)。
1274
- *
1275
- * 落盘在插件自己的目录(与 history.json / pill.json 同级),不碰 cordis.patch.yml:
1276
- * 那个文件是**装配清单**,让 UI 去改它既容易写坏结构,也要重启才生效。
1277
- * 这里的内存值即刻生效(下方所有 config.x 都是每次请求现读的),落盘只为跨重启保留。
1278
- */
1279
- let settingsOverrides = {};
1280
- let settingsLoaded = false;
1281
- let settingsTimer = null;
1282
- const settingsFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'settings.json');
1283
- /**
1284
- * 把覆盖值套用到 config 上。
1285
- *
1286
- * 只接受 SETTINGS_KEYS 里的键,并且**类型必须与默认值一致** ——
1287
- * 文件是手工可编辑的(也可能被旧版本写坏),不能让它把 boolean 写成字符串,
1288
- * 那会让 `if (config.tools)` 这种判断悄悄变成永真。
1289
- */
1290
- function applyOverrides(patch) {
1291
- const target = config;
1292
- for (const [key, value] of Object.entries(patch)) {
1293
- if (!SETTINGS_KEYS.includes(key) && !PERSISTED_SETTING_KEYS.includes(key))
1294
- continue;
1295
- if (typeof value !== typeof configDefaults[key])
1296
- continue;
1297
- target[key] = value;
1298
- }
1299
- }
1300
- /** 读设置覆盖文件(进程内只读一次,与 loadHistory / loadPillPos 同节奏)。 */
1301
- async function loadSettings() {
1302
- if (settingsLoaded)
1303
- return;
1304
- settingsLoaded = true;
1305
- try {
1306
- const parsed = JSON.parse(await readFile(settingsFile(), 'utf8'));
1307
- const raw = parsed?.values;
1308
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1309
- const values = normalizeStoredSettings(raw);
1310
- settingsOverrides = values;
1311
- applyOverrides(values);
1312
- }
1313
- }
1314
- catch {
1315
- // 没存过 / 坏了都当作"没改过":出厂默认本来就是可用状态,没必要因此报错
1316
- settingsOverrides = {};
1317
- }
1318
- }
1319
- /** 落盘(防抖:拖数字输入框会连发多次,没必要每次都写文件)。 */
1320
- function scheduleSettingsWrite() {
1321
- if (settingsTimer)
1322
- clearTimeout(settingsTimer);
1323
- settingsTimer = setTimeout(() => {
1324
- settingsTimer = null;
1325
- void (async () => {
1326
- try {
1327
- await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
1328
- await writeFile(settingsFile(), JSON.stringify({ version: 1, values: settingsOverrides }, null, 0), 'utf8');
1329
- }
1330
- catch (error) {
1331
- ctx.logger?.warn?.(`[${name}] 写入设置失败:${String(error?.message ?? error)}`);
1332
- }
1333
- })();
1334
- }, 300);
1335
- settingsTimer.unref?.();
1336
- }
1337
- /** 当前生效值(按 spec 的键序取,客户端可以直接对比 defaults 判断改过没有)。 */
1338
- function currentSettings() {
1339
- const target = config;
1340
- const values = {};
1341
- for (const key of SETTINGS_KEYS)
1342
- values[key] = target[key];
1343
- return values;
1344
- }
1345
- /** 兜底路由:第一次真实 LLM 调用后捕获。 */
1346
- let captured = null;
1347
- ctx.on('llm/stream', (options, next) => {
1348
- captured = { provider: options.provider, model: options.model };
1349
- return next();
1350
- });
1351
- let modelCache = { at: 0, items: [] };
1352
- /** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
1353
- const leakyOnOff = new Set();
1354
- /**
1355
- * 哪些 provider/model **根本不接受 "关" 档**(上游直接 400 invalid_request_error)。
1356
- *
1357
- * 和 leakyOnOff 分开记:那个是"能发但会泄漏",这个是"发了就报错"。
1358
- * 记下来之后不再发 off,直接改用 low —— 这是"最接近关"的可接受档位。
1359
- * 不记的话每次划词都要先失败一轮再重试:用户看到一次报错闪动 + 多一个来回的时延。
1360
- */
1361
- const noOffRoutes = new Set();
1362
- const leakyFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'leaky-off.json');
1363
- let leakyLoaded = false;
1364
- /** 载入"关档会泄漏思考"的模型清单(跨重启保留;文件坏了就当空)。 */
1365
- async function loadLeaky() {
1366
- if (leakyLoaded)
1367
- return;
1368
- leakyLoaded = true;
1369
- try {
1370
- const raw = await readFile(leakyFile(), 'utf8');
1371
- const parsed = JSON.parse(raw);
1372
- let dropped = 0;
1373
- const accept = (item, into) => {
1374
- if (typeof item !== 'string' || !item)
1375
- return;
1376
- // 落盘文件是**跨重启的经验**,但也可能被测试夹具写脏(实测有 p1/m-strong):
1377
- // provider 不存在的条目直接丢掉,并顺手把文件写回干净版。
1378
- if (!isRealProvider(item.split('/')[0] ?? '')) {
1379
- dropped += 1;
1380
- return;
1381
- }
1382
- into.add(item);
1383
- };
1384
- if (Array.isArray(parsed?.routes))
1385
- for (const item of parsed.routes)
1386
- accept(item, leakyOnOff);
1387
- if (Array.isArray(parsed?.noOff))
1388
- for (const item of parsed.noOff)
1389
- accept(item, noOffRoutes);
1390
- if (dropped > 0)
1391
- saveLeaky();
1392
- }
1393
- catch {
1394
- /* 没有这个文件是常态 */
1395
- }
1396
- }
1397
- function saveLeaky() {
1398
- const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff), noOff: Array.from(noOffRoutes) }, null, 0);
1399
- void writeFile(leakyFile(), body, 'utf8').catch((error) => {
1400
- ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String(error?.message ?? error)}`);
1401
- });
1402
- }
1403
- /**
1404
- * 这条 route 的 provider 是不是**这台机器上真有的**。
1405
- *
1406
- * 为什么落盘前要查一下:这两个集合会写进 `~/.dsh/selection-explain/leaky-off.json`,
1407
- * 而请求体里的 provider 是可以伪造的(`resolveRoute` 在清单没加载出来时只做形状校验)。
1408
- * 实测踩过:`test-client.mjs` 的夹具 provider `p1` 被写进了用户真实的落盘文件
1409
- * (`p1/m-strong`、`p1/m-fast`)—— 之后再也没人清掉它。
1410
- * 宁可不记(顶多多撞一次上游,反正有重试),也不要往用户的文件里写不存在的东西。
1411
- */
1412
- function isRealProvider(provider) {
1413
- if (!provider)
1414
- return false;
1415
- try {
1416
- const llm = ctx.get('llm');
1417
- const list = llm?.listProviders?.() ?? [];
1418
- return list.some((item) => String(item?.id ?? '') === provider);
1419
- }
1420
- catch {
1421
- return false;
1422
- }
1423
- }
1424
- /** 记住一个"关档会泄漏"的模型(内存 + 落盘)。 */
1425
- function markLeaky(route) {
1426
- if (leakyOnOff.has(route))
1427
- return;
1428
- if (!isRealProvider(route.split('/')[0] ?? ''))
1429
- return;
1430
- leakyOnOff.add(route);
1431
- if (leakyLoaded)
1432
- saveLeaky();
1433
- }
1434
- /** 记住一个"不接受关档"的模型(内存 + 落盘):下次直接改用 low,不再先撞一次。 */
1435
- function markNoOff(route) {
1436
- if (noOffRoutes.has(route))
1437
- return;
1438
- if (!isRealProvider(route.split('/')[0] ?? ''))
1439
- return;
1440
- noOffRoutes.add(route);
1441
- if (leakyLoaded)
1442
- saveLeaky();
1443
- }
1444
- /** 档位阶梯与判据都在模块层(纯函数,可单测):这里只把清单数据喂给它。 */
1445
- function nextEffortFor(route, rejected, tried) {
1446
- const item = modelCache.items.find((entry) => `${entry.provider}/${entry.model}` === route);
1447
- return pickNextEffort((item?.efforts ?? []).map((effort) => effort.id), item?.defaultEffort, rejected, tried);
1448
- }
1449
- /**
1450
- * 规范档位(设置页那四格)→ 这个 route **这次真要发**的档位('' = 不带 reasoningEffort)。
1451
- *
1452
- * 每次请求都按**当前模型的声明**现算(不缓存映射结果、不落盘):用户换模型、DSH 升级换
1453
- * adapter 之后立刻跟着变。清单里查不到(还没加载过 / 那次调用失败)就原样发规范值 ——
1454
- * 与改动前一致,被拒时由换档重试兜;同时后台预热一次清单,下一次请求就能映射上。
1455
- * 模型明确不支持推理(hasReasoning === false)时不发档位:发了 dsh-llm 必拒。
1456
- */
1457
- function effortForRoute(route, canonical) {
1458
- const item = modelCache.items.find((entry) => `${entry.provider}/${entry.model}` === route);
1459
- if (!item) {
1460
- warmModelCache();
1461
- return canonical;
1462
- }
1463
- if (item.hasReasoning === false)
1464
- return '';
1465
- const mapped = item.effortMap?.[canonical];
1466
- return mapped === undefined || mapped === '' ? canonical : mapped;
1467
- }
1468
- /** 清单预热:只在"真要用却还没读到"时拉一次,60 秒内不重复拉(失败也不打转)。 */
1469
- let modelWarmAt = 0;
1470
- function warmModelCache() {
1471
- if (modelCache.items.length > 0)
1472
- return;
1473
- if (Date.now() - modelWarmAt < MODEL_CACHE_MS)
1474
- return;
1475
- modelWarmAt = Date.now();
1476
- void listModelChoices(false).catch(() => undefined);
1477
- }
1478
- const MODEL_CACHE_MS = 60_000;
1479
- async function listModelChoices(force = false) {
1480
- if (!force && modelCache.items.length > 0 && Date.now() - modelCache.at < MODEL_CACHE_MS)
1481
- return modelCache.items;
1482
- const llm = ctx.get('llm');
1483
- const items = [];
1484
- const providers = llm?.listProviders?.() ?? [];
1485
- for (const provider of providers) {
1486
- if (!provider?.id || typeof llm?.listModels !== 'function')
1487
- continue;
1488
- let models = [];
1489
- try {
1490
- models = (await llm.listModels(provider.id)) ?? [];
1491
- }
1492
- catch {
1493
- models = [];
1494
- }
1495
- for (const model of models) {
1496
- if (!model?.id)
1497
- continue;
1498
- let efforts = [];
1499
- let defaultEffort = null;
1500
- // null = 没读到(调用失败 / 拿不到信息);true/false = 明确支持 / 不支持
1501
- let hasReasoning = null;
1502
- try {
1503
- let info = null;
1504
- if (typeof llm.resolveModelInfo === 'function') {
1505
- info = (await llm.resolveModelInfo(provider.id, model.id));
1506
- }
1507
- else if (typeof llm.resolveModel === 'function') {
1508
- info = (await llm.resolveModel(provider.id, model.id));
1509
- }
1510
- if (info) {
1511
- const list = info.reasoning?.efforts ?? [];
1512
- efforts = list
1513
- .filter((item) => typeof item?.id === 'string' && item.id.length > 0)
1514
- .map((item) => ({ id: String(item.id), name: String(item.name ?? item.id) }));
1515
- defaultEffort = typeof info.reasoning?.defaultEffort === 'string' ? info.reasoning.defaultEffort : null;
1516
- // 声明了 reasoning 但档位列表为空 → 发任何档位 dsh-llm 都会拒(它自己也不接受空表)
1517
- hasReasoning = efforts.length > 0;
1518
- }
1519
- }
1520
- catch {
1521
- efforts = [];
1522
- }
1523
- items.push({
1524
- provider: provider.id,
1525
- providerName: String(provider.name ?? provider.id),
1526
- model: model.id,
1527
- name: String(model.name ?? model.id),
1528
- efforts,
1529
- defaultEffort,
1530
- hasReasoning,
1531
- effortMap: effortMapOf(efforts),
1532
- });
1533
- }
1534
- }
1535
- if (items.length > 0)
1536
- modelCache = { at: Date.now(), items };
1537
- return items;
1538
- }
1539
- /** 请求里带 provider+model 时优先用它(并按清单校验;清单还没加载出来就只做形状校验)。 */
1540
- const resolveRoute = (override,
1541
- /**
1542
- * **当前会话**自己的模型选择(见 sessionModelOf)。
1543
- *
1544
- * 为什么要有它:会话是各自选模型的(每个 Agent 一份 model-selection),
1545
- * 而 `agentDefaultModel.currentSelection()` 是**全局默认**、与会话无关。
1546
- * 以前只用后者,于是"跟随主会话"其实跟的是"全局默认"——
1547
- * 两个会话用不同模型时,插件对两个会话都用同一个默认模型,跟谁都对不上。
1548
- *
1549
- * 优先级:显式 override(小窗胶囊选的)→ 插件配置(设置页选的)→ **本会话的模型** → 全局默认 → 第一个可用。
1550
- * 会话没记过选择(新会话、或日志里没有 model/selection)时自然落回全局默认,行为与以前一致。
1551
- */
1552
- sessionModel,
1553
- /**
1554
- * 这一轮是不是**追问**。
1555
- *
1556
- * 三个阶段沿用同一模型;旧 chatProvider/chatModel 仅保留配置兼容,不再参与路由。
1557
- */
1558
- chat) => {
1559
- if (override?.provider && override?.model) {
1560
- const wanted = { provider: override.provider, model: override.model };
1561
- const known = modelCache.items.some((item) => item.provider === wanted.provider && item.model === wanted.model);
1562
- if (known || modelCache.items.length === 0)
1563
- return wanted;
1564
- }
1565
- if (config.provider && config.model)
1566
- return { provider: config.provider, model: config.model };
1567
- if (sessionModel?.provider && sessionModel?.model) {
1568
- return { provider: sessionModel.provider, model: sessionModel.model };
1569
- }
1570
- const service = ctx.get('agentDefaultModel');
1571
- const selection = service?.currentSelection?.();
1572
- if (selection?.provider && selection?.model) {
1573
- return { provider: selection.provider, model: selection.model };
1574
- }
1575
- const llm = ctx.get('llm');
1576
- const first = llm?.listProviders?.()?.find((item) => (item.models?.length ?? 0) > 0);
1577
- if (first?.id && first.models?.[0]?.id)
1578
- return { provider: first.id, model: first.models[0].id };
1579
- return captured;
1580
- };
1581
- /** 分钟级限流。 */
1582
- const hits = [];
1583
- const allow = () => {
1584
- const now = Date.now();
1585
- while (hits.length > 0 && now - hits[0] > 60_000)
1586
- hits.shift();
1587
- if (hits.length >= config.maxRequestsPerMinute)
1588
- return false;
1589
- hits.push(now);
1590
- return true;
1591
- };
1592
- /**
1593
- * 共享结果缓存(进程级,跨标签页/刷新可用):
1594
- * 同会话 + 同选中文字 + 同局部片段 + 同会话背景 → 直接回放上次结果,不再调用模型。
1595
- */
1596
- const resultCache = new Map();
1597
- const resultKeyOf = (provider, model, sessionId, backgroundText, text, context) => createHash('sha1').update([provider, model, sessionId, backgroundText, text, context].join('\u0000')).digest('hex');
1598
- /** 会话背景短缓存:同一会话 10 秒内复用(连续划词不重复读日志)。 */
1599
- const backgroundCache = new Map();
1600
- /**
1601
- * 「本会话在用哪个模型」的短缓存 —— 只给按秒轮询的 `/route` 用(见 sessionModelOf)。
1602
- *
1603
- * 为什么需要:`readSession` 会把整份日志重放成一个 Session、再逐条深拷贝一遍
1604
- * (本机实测 1.5MB 的会话一次 80~125ms)。跟随状态下**小窗一开着**就要每 2 秒问一次,
1605
- * 不缓存等于每 2 秒在宿主进程里重放一次会话日志。
1606
- *
1607
- * 变更探测用 live 会话的 `seq`(同步、零成本,见 liveSeqOf):没涨就直接回缓存;
1608
- * 涨了(主会话正在连续对话时每轮都在涨)也至少隔 SESSION_MODEL_MIN_MS 才重读一次,
1609
- * 否则"小窗开着 + 主会话在聊"时每 2 秒照样重放。非 live(磁盘上的旧会话,读不到 seq)按 TTL 兜底。
1610
- */
1611
- const sessionModelCache = new Map();
1612
- /** 同一会话正在飞的读取(读可能比轮询间隔还慢,叠起来没意义)。 */
1613
- const sessionModelPending = new Map();
1614
- /** live 会话两次重读之间的最短间隔(挡住"事件一直在涨"时的连续重放)。 */
1615
- const SESSION_MODEL_MIN_MS = 3_000;
1616
- /** 读不到 seq(非 live)时的兜底 TTL。 */
1617
- const SESSION_MODEL_TTL_MS = 10_000;
1618
- /**
1619
- * live 会话的事件数(`Session.seq` = 日志长度,每次 append 都会涨)。
1620
- *
1621
- * 这是**零成本**的同步读取:不用读日志、不用重放,就能判断"会话有没有新事件"。
1622
- * 拿不到(会话不在内存里 / 老内核没有 sessions 服务)返回 null,调用方退回 TTL。
1623
- */
1624
- const liveSeqOf = (sessionId) => {
1625
- const sessions = ctx.get('sessions');
1626
- const seq = sessions?.get?.(sessionId)?.seq;
1627
- return typeof seq === 'number' ? seq : null;
1628
- };
1629
- /** 读取当前会话的模型界面事件(live 优先;失败/超时一律降级为「无背景」)。 */
1630
- const readSurfaceEvents = async (sessionId) => {
1631
- const cached = backgroundCache.get(sessionId);
1632
- if (cached && Date.now() - cached.at < 10_000)
1633
- return cached.events;
1634
- const sessionQuery = ctx.get('sessionQuery');
1635
- if (typeof sessionQuery?.readSurface !== 'function')
1636
- return [];
1637
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 2000));
1638
- try {
1639
- const surface = await Promise.race([sessionQuery.readSurface(sessionId), timeout]);
1640
- const events = surface && Array.isArray(surface.events) ? surface.events : [];
1641
- backgroundCache.set(sessionId, { at: Date.now(), events });
1642
- if (backgroundCache.size > 8) {
1643
- const oldest = backgroundCache.keys().next().value;
1644
- if (oldest !== undefined)
1645
- backgroundCache.delete(oldest);
1646
- }
1647
- return events;
1648
- }
1649
- catch (error) {
1650
- ctx.logger?.warn?.(`[${name}] 读取会话背景失败(${sessionId}):${String(error?.message ?? error)}`);
1651
- return [];
1652
- }
1653
- };
1654
- /**
1655
- * 真读一次:拿会话原始事件里的最后一条 `model/selection`。
1656
- *
1657
- * 来源:会话事件流里的 `model/selection`(每条 = 一次"后续请求用这个模型"的选择,
1658
- * 形如 `{ type:'model/selection', data:{ provider, model, reasoningEffort? } }`)。
1659
- * 取**最后一条** = 会话当前在用的那个;一条都没有(新会话)就返回 null,调用方落回全局默认。
1660
- */
1661
- const readSessionModel = async (sessionId) => {
1662
- if (!sessionId)
1663
- return null;
1664
- const sessionQuery = ctx.get('sessionQuery');
1665
- if (typeof sessionQuery?.readSession !== 'function')
1666
- return null;
1667
- let events = [];
1668
- try {
1669
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 2000));
1670
- const log = await Promise.race([sessionQuery.readSession(sessionId), timeout]);
1671
- events = log && Array.isArray(log.events) ? (log.events) : [];
1672
- }
1673
- catch {
1674
- return null;
1675
- }
1676
- for (let i = events.length - 1; i >= 0; i -= 1) {
1677
- const event = events[i];
1678
- if (!event || typeof event !== 'object')
1679
- continue;
1680
- const typed = event;
1681
- if (typed.type !== 'model/selection')
1682
- continue;
1683
- const data = (typed.data ?? {});
1684
- if (typeof data.provider === 'string' && data.provider && typeof data.model === 'string' && data.model) {
1685
- return { provider: data.provider, model: data.model };
1686
- }
1687
- }
1688
- return null;
1689
- };
1690
- /**
1691
- * 记下这次的结论(连同"读到它时的 seq"):下次轮询就能靠 seq 判断结论还有效没。
1692
- *
1693
- * 顺手也把"用户动作路径"读到的值种进缓存 —— 那是**此刻**那一份,
1694
- * 种进来只会让轮询更少重读、更不容易报旧值。
1695
- */
1696
- const rememberSessionModel = (sessionId, seq, value) => {
1697
- sessionModelCache.set(sessionId, { at: Date.now(), seq, value });
1698
- if (sessionModelCache.size > 8) {
1699
- const oldest = sessionModelCache.keys().next().value;
1700
- if (oldest !== undefined)
1701
- sessionModelCache.delete(oldest);
1702
- }
1703
- };
1704
- /**
1705
- * 读**当前会话**自己的模型选择。
1706
- *
1707
- * `useCache` 只给按秒轮询的 `/route` 打开;用户动作路径(analyze / models / ping)一律直读,
1708
- * 拿到的必须是**此刻**那一份 —— 不能让人刚在主会话里换完模型、划词却按旧模型解析。
1709
- * 直读的结果会顺手种进缓存(见 rememberSessionModel)。
1710
- */
1711
- const sessionModelOf = async (sessionId, useCache = false) => {
1712
- if (!sessionId)
1713
- return null;
1714
- if (!useCache) {
1715
- // seq 在读**之前**取:读的过程中若又落了事件,缓存就应当被视为旧值(宁可下次重读)
1716
- const seqNow = liveSeqOf(sessionId);
1717
- const value = await readSessionModel(sessionId);
1718
- rememberSessionModel(sessionId, seqNow, value);
1719
- return value;
1720
- }
1721
- const seq = liveSeqOf(sessionId);
1722
- const cached = sessionModelCache.get(sessionId);
1723
- if (cached) {
1724
- // 事件没涨 → 结论不可能变。只对 live 会话成立:`seq` 是同步的,每次 append 都会涨。
1725
- if (seq !== null && cached.seq === seq)
1726
- return cached.value;
1727
- // 涨了也不立刻重读:主会话正在连续对话时事件一直在涨,否则每 2 秒照样重放一次日志。
1728
- const floor = seq === null ? SESSION_MODEL_TTL_MS : SESSION_MODEL_MIN_MS;
1729
- if (Date.now() - cached.at < floor)
1730
- return cached.value;
1731
- }
1732
- // 同一会话的轮询叠在一起(读可能比轮询间隔还慢)时共用一个在飞的请求
1733
- const pending = sessionModelPending.get(sessionId);
1734
- if (pending)
1735
- return pending;
1736
- const task = readSessionModel(sessionId)
1737
- .then((value) => {
1738
- rememberSessionModel(sessionId, seq, value);
1739
- return value;
1740
- })
1741
- .finally(() => {
1742
- sessionModelPending.delete(sessionId);
1743
- });
1744
- sessionModelPending.set(sessionId, task);
1745
- return task;
1746
- };
1747
- /** 组装会话背景文本。 */
1748
- const loadSessionBackground = async (sessionId, options) => {
1749
- const events = await readSurfaceEvents(sessionId);
1750
- if (events.length === 0)
1751
- return null;
1752
- const background = transcriptOf(events, options);
1753
- return background.transcript ? background : null;
1754
- };
1755
- const history = new Map();
1756
- let historyLoaded = false;
1757
- let historyTimer = null;
1758
- const historyFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'history.json');
1759
- /**
1760
- * 悬浮胶囊"拖到别处"后的位置(用户要求记住,存 host 上 —— 不用 localStorage)。
1761
- * 只记自由摆放的**右边缘 + 垂直中心**:收球/展开都以右边缘为锚(client 半的 applyFreePill 用 CSS right 钉住)。
1762
- * 吸附回右下角(anchor)时会清掉 —— 那是默认位置,不需要记。
1763
- */
1764
- const pillPosFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'pill.json');
1765
- let pillPos = null;
1766
- let pillPosLoaded = false;
1767
- let pillPosTimer = null;
1768
- /** 位置校验:只接受有限数值,并夹到一个"任何屏幕都放得下"的量级。 */
1769
- function normalizePillPos(input) {
1770
- const value = input;
1771
- const right = typeof value?.right === 'number' && Number.isFinite(value.right) ? value.right : NaN;
1772
- const y = typeof value?.y === 'number' && Number.isFinite(value.y) ? value.y : NaN;
1773
- if (!Number.isFinite(right) || !Number.isFinite(y))
1774
- return null;
1775
- const clamp = (n) => Math.round(Math.min(40000, Math.max(-40000, n)));
1776
- return { right: clamp(right), y: clamp(y) };
1777
- }
1778
- async function loadPillPos() {
1779
- if (pillPosLoaded)
1780
- return;
1781
- pillPosLoaded = true;
1782
- try {
1783
- const parsed = JSON.parse(await readFile(pillPosFile(), 'utf8'));
1784
- pillPos = normalizePillPos(parsed?.position);
1785
- }
1786
- catch {
1787
- pillPos = null; // 没存过 / 坏了都当作"没记过"
1788
- }
1789
- }
1790
- function schedulePillPosWrite() {
1791
- if (pillPosTimer)
1792
- clearTimeout(pillPosTimer);
1793
- pillPosTimer = setTimeout(() => {
1794
- pillPosTimer = null;
1795
- void (async () => {
1796
- try {
1797
- await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
1798
- await writeFile(pillPosFile(), JSON.stringify({ version: 1, position: pillPos }, null, 0), 'utf8');
1799
- }
1800
- catch (error) {
1801
- ctx.logger?.warn?.(`[${name}] 写入胶囊位置失败:${String(error?.message ?? error)}`);
1802
- }
1803
- })();
1804
- }, 300);
1805
- pillPosTimer.unref?.();
1806
- }
1807
- /**
1808
- * 小窗的**尺寸**(拖角改过大小之后记下来)。
1809
- *
1810
- * 只记尺寸,**不记位置**:小窗每次开窗都由 client 按选区重新锚定
1811
- * (落在选区下方,放不下就翻到上方,再夹进视口)—— 那套锚定逻辑是既有的设计,
1812
- * 位置一旦被"记住"就等于架空了它,开窗不再跟着划词的地方走。
1813
- * 尺寸则是用户明确调过的东西,值得跨开窗保留。
1814
- */
1815
- const panelSizeFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'panel.json');
1816
- let panelSize = null;
1817
- let panelSizeLoaded = false;
1818
- let panelSizeTimer = null;
1819
- /**
1820
- * 尺寸校验:两个数都得是有限值,并夹到一个合理的区间。
1821
- *
1822
- * client 那边已经有 min/max 了,但文件是手工可编辑的(也可能被旧版本写坏),
1823
- * 不能让一个 5px 宽的小窗把界面弄成没法用。
1824
- */
1825
- function normalizePanelSize(input) {
1826
- const value = input;
1827
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : NaN);
1828
- const w = num(value?.w);
1829
- const h = num(value?.h);
1830
- if (!Number.isFinite(w) || !Number.isFinite(h))
1831
- return null;
1832
- const clamp = (n, lo, hi) => Math.round(Math.min(hi, Math.max(lo, n)));
1833
- return { w: clamp(w, 280, 4000), h: clamp(h, 200, 4000) };
1834
- }
1835
- async function loadPanelSize() {
1836
- if (panelSizeLoaded)
1837
- return;
1838
- panelSizeLoaded = true;
1839
- try {
1840
- const parsed = JSON.parse(await readFile(panelSizeFile(), 'utf8'));
1841
- panelSize = normalizePanelSize(parsed?.size);
1842
- }
1843
- catch {
1844
- panelSize = null; // 没存过 / 坏了都当作"没调过"
1845
- }
1846
- }
1847
- function schedulePanelSizeWrite() {
1848
- if (panelSizeTimer)
1849
- clearTimeout(panelSizeTimer);
1850
- panelSizeTimer = setTimeout(() => {
1851
- panelSizeTimer = null;
1852
- void (async () => {
1853
- try {
1854
- await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
1855
- await writeFile(panelSizeFile(), JSON.stringify({ version: 1, size: panelSize }, null, 0), 'utf8');
1856
- }
1857
- catch (error) {
1858
- ctx.logger?.warn?.(`[${name}] 写入小窗尺寸失败:${String(error?.message ?? error)}`);
1859
- }
1860
- })();
1861
- }, 300);
1862
- panelSizeTimer.unref?.();
1863
- }
1864
- /**
1865
- * 小窗尺寸:GET 读回来(挂载时恢复)、POST 存({w,h})或清({clear:true},回到默认尺寸)。
1866
- */
1867
- const handlePanelSize = (req, res) => {
1868
- void (async () => {
1869
- await loadPanelSize();
1870
- if (req.method === 'GET') {
1871
- sendJson(res, 200, { ok: true, size: panelSize });
1872
- return;
1873
- }
1874
- if (req.method !== 'POST') {
1875
- sendJson(res, 405, { ok: false, error: '只支持 GET / POST' });
1876
- return;
1877
- }
1878
- const raw = await readBody(req, 4_000);
1879
- let body = {};
1880
- try {
1881
- body = raw ? JSON.parse(raw) : {};
1882
- }
1883
- catch {
1884
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
1885
- return;
1886
- }
1887
- if (body.clear === true) {
1888
- panelSize = null;
1889
- schedulePanelSizeWrite();
1890
- sendJson(res, 200, { ok: true, size: null });
1891
- return;
1892
- }
1893
- const next = normalizePanelSize(body.size ?? body);
1894
- if (!next) {
1895
- sendJson(res, 400, { ok: false, error: 'size 需要 { w, h } 两个有限数值' });
1896
- return;
1897
- }
1898
- panelSize = next;
1899
- schedulePanelSizeWrite();
1900
- sendJson(res, 200, { ok: true, size: panelSize });
1901
- })().catch((error) => {
1902
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
1903
- });
1904
- };
1905
- /**
1906
- * 胶囊位置:GET 读回来(挂载时恢复)、POST 存({right,y})或清({clear:true},吸附回右下角时调)。
1907
- */
1908
- const handlePillPos = (req, res) => {
1909
- void (async () => {
1910
- await loadPillPos();
1911
- if (req.method === 'GET') {
1912
- sendJson(res, 200, { ok: true, position: pillPos });
1913
- return;
1914
- }
1915
- if (req.method !== 'POST') {
1916
- sendJson(res, 405, { ok: false, error: '只支持 GET / POST' });
1917
- return;
1918
- }
1919
- const raw = await readBody(req, 4_000);
1920
- let body = {};
1921
- try {
1922
- body = raw ? JSON.parse(raw) : {};
1923
- }
1924
- catch {
1925
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
1926
- return;
1927
- }
1928
- if (body.clear === true) {
1929
- pillPos = null;
1930
- schedulePillPosWrite();
1931
- sendJson(res, 200, { ok: true, position: null });
1932
- return;
1933
- }
1934
- const next = normalizePillPos(body.position ?? body);
1935
- if (!next) {
1936
- sendJson(res, 400, { ok: false, error: 'position 需要 { right, y } 两个有限数值' });
1937
- return;
1938
- }
1939
- pillPos = next;
1940
- schedulePillPosWrite();
1941
- sendJson(res, 200, { ok: true, position: pillPos });
1942
- })();
1943
- };
1944
- const clampText = (value, max) => (typeof value === 'string' ? value.slice(0, max) : '');
1945
- async function loadHistory() {
1946
- if (historyLoaded)
1947
- return;
1948
- historyLoaded = true;
1949
- try {
1950
- await loadLeaky();
1951
- const raw = await readFile(historyFile(), 'utf8');
1952
- const parsed = JSON.parse(raw);
1953
- const list = Array.isArray(parsed?.entries) ? parsed.entries : [];
1954
- for (const item of list) {
1955
- const entry = item;
1956
- if (typeof entry?.key !== 'string' || entry.key.length === 0)
1957
- continue;
1958
- history.set(entry.key, {
1959
- key: entry.key,
1960
- // 按可设置的最高选区长度保留,调低当前限制也不截断已有历史。
1961
- text: clampText(entry.text, 20000),
1962
- context: clampText(entry.context, 20000),
1963
- label: clampText(entry.label, 60),
1964
- at: typeof entry.at === 'number' ? entry.at : Date.now(),
1965
- parts: {
1966
- translation: clampText(entry.parts?.translation, 20000),
1967
- detail: clampText(entry.parts?.detail, 20000),
1968
- },
1969
- turns: Array.isArray(entry.turns)
1970
- ? entry.turns.slice(0, 60).map((turn) => ({
1971
- role: turn?.role === 'assistant' ? 'assistant' : 'user',
1972
- text: clampText(turn?.text, 20000),
1973
- }))
1974
- : [],
1975
- toolDigest: clampText(entry.toolDigest, 2000),
1976
- pinned: entry.pinned === true,
1977
- });
1978
- }
1979
- ctx.logger?.info?.(`[${name}] 小窗历史已载入 ${String(history.size)} 条(${historyFile()})`);
1980
- }
1981
- catch (error) {
1982
- const code = error?.code;
1983
- if (code !== 'ENOENT') {
1984
- ctx.logger?.warn?.(`[${name}] 读取小窗历史失败:${String(error?.message ?? error)}`);
1985
- }
1986
- }
1987
- }
1988
- function scheduleHistoryWrite() {
1989
- if (historyTimer)
1990
- clearTimeout(historyTimer);
1991
- historyTimer = setTimeout(() => {
1992
- historyTimer = null;
1993
- const entries = [...history.values()].sort((a, b) => b.at - a.at);
1994
- void (async () => {
1995
- try {
1996
- await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
1997
- await writeFile(historyFile(), JSON.stringify({ version: 1, entries }, null, 0), 'utf8');
1998
- }
1999
- catch (error) {
2000
- ctx.logger?.warn?.(`[${name}] 写入小窗历史失败:${String(error?.message ?? error)}`);
2001
- }
2002
- })();
2003
- }, 800);
2004
- historyTimer.unref?.();
2005
- }
2006
- /** 存一条对话(整段覆盖),并按 LRU 淘汰超出上限的条目(升格过的不淘汰)。 */
2007
- function saveHistoryEntry(input) {
2008
- if (config.historyMaxEntries <= 0)
2009
- return history.size;
2010
- const key = clampText(input.key, 200);
2011
- if (!key)
2012
- return history.size;
2013
- const previous = history.get(key);
2014
- const entry = {
2015
- key,
2016
- text: clampText(input.text, 20000),
2017
- context: clampText(input.context, 20000),
2018
- label: clampText(input.label, 60),
2019
- at: Date.now(),
2020
- parts: {
2021
- translation: clampText(input.parts?.translation, 20000),
2022
- detail: clampText(input.parts?.detail, 20000),
2023
- },
2024
- turns: Array.isArray(input.turns)
2025
- ? input.turns.slice(0, 60).map((turn) => ({
2026
- role: turn?.role === 'assistant' ? 'assistant' : 'user',
2027
- text: clampText(turn?.text, 20000),
2028
- }))
2029
- : [],
2030
- toolDigest: clampText(input.toolDigest, 2000),
2031
- pinned: input.pinned === true || previous?.pinned === true,
2032
- };
2033
- history.delete(key);
2034
- history.set(key, entry);
2035
- // LRU:超出上限时丢最久没用过的(Map 的插入顺序就是使用顺序)
2036
- while (history.size > config.historyMaxEntries) {
2037
- let victim;
2038
- for (const candidate of history.keys()) {
2039
- const item = history.get(candidate);
2040
- if (item && !item.pinned) {
2041
- victim = candidate;
2042
- break;
2043
- }
2044
- }
2045
- if (victim === undefined)
2046
- break;
2047
- history.delete(victim);
2048
- }
2049
- scheduleHistoryWrite();
2050
- return history.size;
2051
- }
2052
- const historyMeta = (entry) => ({
2053
- key: entry.key,
2054
- text: entry.text.length > 60 ? `${entry.text.slice(0, 60)}…` : entry.text,
2055
- label: entry.label,
2056
- at: entry.at,
2057
- turns: entry.turns.filter((turn) => turn.role === 'user').length,
2058
- hasDetail: entry.parts.detail.length > 0,
2059
- pinned: entry.pinned,
2060
- });
2061
- /**
2062
- * 引用上下文:客户端点了「❝ 引用」之后,拿引用文字来换它所在的那一组对话 ± 一组。
2063
- *
2064
- * 为什么走 host 而不是客户端读 DOM:会话正文的**干净文本**只有 host 这边有
2065
- * (`readSurface` 的事件流里工具调用/结果、系统提示、harness 注入全都被 `transcriptOf`
2066
- * 那套规则滤掉过)。客户端 DOM 里读到的会是"渲染后的样子"——思考块、工具卡片、
2067
- * 按钮文案全混在里面。定位不到(引用来自侧边栏网页/文档预览)就返回 matched:false,
2068
- * 客户端退回它自己采的局部上下文。
2069
- */
2070
- const handleQuoteContext = (req, res) => {
2071
- void (async () => {
2072
- if (req.method !== 'POST') {
2073
- sendJson(res, 405, { ok: false, error: '只支持 POST' });
2074
- return;
2075
- }
2076
- const raw = await readBody(req, 200_000);
2077
- let body = {};
2078
- try {
2079
- body = raw ? JSON.parse(raw) : {};
2080
- }
2081
- catch {
2082
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
2083
- return;
2084
- }
2085
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
2086
- const text = clampText(body.text, config.maxSelectionChars);
2087
- if (!sessionId || !text.trim()) {
2088
- sendJson(res, 200, { ok: true, matched: false, context: '', rounds: 0 });
2089
- return;
2090
- }
2091
- const events = await readSurfaceEvents(sessionId);
2092
- const context = quoteContextOf(events, text, {
2093
- roundsAround: config.quoteContextRounds,
2094
- maxCharsPerTurn: config.quoteContextMaxCharsPerTurn,
2095
- maxChars: config.quoteContextMaxChars,
2096
- });
2097
- sendJson(res, 200, {
2098
- ok: true,
2099
- matched: context.matched,
2100
- context: context.transcript,
2101
- rounds: context.rounds,
2102
- chars: context.transcript.length,
2103
- });
2104
- })().catch((error) => {
2105
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2106
- });
2107
- };
2108
- /** ctx.speechToText:装了就给,没装/被停用给 undefined(麦克风据此优雅降级)。 */
2109
- const speechService = () => {
2110
- try {
2111
- const service = ctx.get('speechToText');
2112
- return service && typeof service.transcribe === 'function' ? service : undefined;
2113
- }
2114
- catch {
2115
- return undefined;
2116
- }
2117
- };
2118
- /**
2119
- * 语音目录:有哪些识别器、各自什么状态、默认选谁、这次能录多久。
2120
- *
2121
- * 客户端据此决定麦克风的形态:能用 / 要先把模型准备好 / 这个部署根本没有识别服务。
2122
- * 不认识的状态一律当成"不能录"(宁可让用户看到一句解释,也不要录完才发现转不了)。
2123
- */
2124
- const speechCatalog = () => {
2125
- const service = speechService();
2126
- const limits = { maxSeconds: SPEECH_MAX_SECONDS, maxBytes: SPEECH_MAX_BYTES };
2127
- if (!service) {
2128
- return {
2129
- ok: true,
2130
- available: false,
2131
- reason: 'missing',
2132
- error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
2133
- providers: [],
2134
- selection: null,
2135
- limits,
2136
- };
2137
- }
2138
- let snapshot;
2139
- try {
2140
- snapshot = service.snapshot();
2141
- }
2142
- catch (error) {
2143
- return {
2144
- ok: true,
2145
- available: false,
2146
- reason: 'error',
2147
- error: String(error?.message ?? error),
2148
- providers: [],
2149
- selection: null,
2150
- limits,
2151
- };
2152
- }
2153
- const providers = (snapshot?.providers ?? []).map((provider) => {
2154
- const preparation = provider.preparation ?? { phase: 'ready' };
2155
- return {
2156
- id: String(provider.id),
2157
- name: String(provider.name ?? provider.id),
2158
- location: String(provider.location ?? ''),
2159
- languages: Array.isArray(provider.languages) ? provider.languages.map(String) : [],
2160
- // 下载源:本地模型已经装好时是空数组(没有可选项),装了才有得选
2161
- downloadSources: Array.isArray(provider.downloadSources) ? provider.downloadSources.map(String) : [],
2162
- phase: String(preparation.phase ?? 'unprepared'),
2163
- step: preparation.step ? String(preparation.step) : '',
2164
- // 下载进度(只在 downloading 时有意义):客户端显示"还要下多少"
2165
- completedBytes: typeof preparation.completedBytes === 'number' ? preparation.completedBytes : 0,
2166
- totalBytes: typeof preparation.totalBytes === 'number' ? preparation.totalBytes : 0,
2167
- message: preparation.message ? String(preparation.message) : '',
2168
- };
2169
- });
2170
- const selection = snapshot?.selection
2171
- ? { providerId: String(snapshot.selection.providerId), language: String(snapshot.selection.language) }
2172
- : null;
2173
- // 能录 = 至少有一个识别器已经就绪(standby 也算:本机模型在磁盘上,用到才唤醒)
2174
- const usable = providers.filter((provider) => provider.phase === 'ready' || provider.phase === 'standby');
2175
- return {
2176
- ok: true,
2177
- available: usable.length > 0,
2178
- reason: usable.length > 0 ? '' : providers.length > 0 ? 'unprepared' : 'empty',
2179
- error: '',
2180
- providers,
2181
- selection,
2182
- limits,
2183
- };
2184
- };
2185
- const handleSpeech = (req, res) => {
2186
- if (req.method !== 'GET') {
2187
- sendJson(res, 405, { ok: false, error: '只支持 GET' });
2188
- return;
2189
- }
2190
- sendJson(res, 200, speechCatalog());
2191
- };
2192
- /**
2193
- * 显式准备识别模型(首次要下 1G 左右的模型,所以**只有用户点了才做**,不自动触发)。
2194
- * 只是把任务交给识别服务,进度走目录接口轮询(下载在 host 上跑,关掉页面也继续)。
2195
- */
2196
- const handleSpeechPrepare = (req, res) => {
2197
- void (async () => {
2198
- if (req.method !== 'POST') {
2199
- sendJson(res, 405, { ok: false, error: '只支持 POST' });
2200
- return;
2201
- }
2202
- const service = speechService();
2203
- if (!service) {
2204
- sendJson(res, 200, { ok: false, error: '这个 DSH 没有启用语音识别服务' });
2205
- return;
2206
- }
2207
- let body = {};
2208
- try {
2209
- const raw = await readBody(req, 20_000);
2210
- body = raw ? JSON.parse(raw) : {};
2211
- }
2212
- catch {
2213
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
2214
- return;
2215
- }
2216
- const catalog = speechCatalog();
2217
- const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
2218
- const providers = catalog.providers ?? [];
2219
- const target = wanted || (providers.find((item) => item.phase !== 'ready' && item.phase !== 'standby')?.id ?? providers[0]?.id ?? '');
2220
- if (!target) {
2221
- sendJson(res, 200, { ok: false, error: '没有可用的识别器' });
2222
- return;
2223
- }
2224
- try {
2225
- const source = typeof body.downloadSource === 'string' && body.downloadSource ? body.downloadSource : undefined;
2226
- service.prepare(target, source === undefined ? undefined : { downloadSource: source });
2227
- }
2228
- catch (error) {
2229
- // 已经在准备中 / 下载源不认 / 服务生命周期已结束:都不算致命,把状态回给客户端自己看
2230
- sendJson(res, 200, { ok: false, error: String(error?.message ?? error), catalog: speechCatalog() });
2231
- return;
2232
- }
2233
- sendJson(res, 200, { ok: true, providerId: target, catalog: speechCatalog() });
2234
- })().catch((error) => {
2235
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2236
- });
2237
- };
2238
- /**
2239
- * 转写一段录音。
2240
- *
2241
- * 输入是浏览器端编码好的规范 WAV(base64),输出就是一段文字——**不落盘、不进会话**,
2242
- * 也不给模型看(用户要的是"把我说的变成字",不是"让模型处理我的录音")。
2243
- * 识别失败一律回 200 + { ok:false, code }:这是"这一句话没听清",
2244
- * 不是 HTTP 层面的错误,客户端按 code 给不同的话术(换 HTTP 状态码反而会被
2245
- * 各种中间层改写或吞掉,用户看到的就只剩"请求失败")。
2246
- */
2247
- const handleSpeechTranscribe = (req, res) => {
2248
- void (async () => {
2249
- if (req.method !== 'POST') {
2250
- sendJson(res, 405, { ok: false, error: '只支持 POST' });
2251
- return;
2252
- }
2253
- const service = speechService();
2254
- if (!service) {
2255
- sendJson(res, 200, {
2256
- ok: false,
2257
- code: 'missing',
2258
- error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
2259
- });
2260
- return;
2261
- }
2262
- let body = {};
2263
- try {
2264
- const raw = await readBody(req, SPEECH_BODY_LIMIT);
2265
- body = raw ? JSON.parse(raw) : {};
2266
- }
2267
- catch (error) {
2268
- sendJson(res, 200, { ok: false, code: 'transport', error: String(error?.message ?? error) });
2269
- return;
2270
- }
2271
- const encoded = typeof body.audioBase64 === 'string' ? body.audioBase64 : '';
2272
- if (!encoded) {
2273
- sendJson(res, 200, { ok: false, code: 'audio', error: '没有收到音频' });
2274
- return;
2275
- }
2276
- let audio;
2277
- let seconds = 0;
2278
- try {
2279
- if (encoded.length > Math.ceil(SPEECH_MAX_BYTES / 3) * 4)
2280
- throw new Error('录音太大了');
2281
- audio = Buffer.from(encoded, 'base64');
2282
- // 规范 base64:随手传一段别的东西进来会在这一步被挡掉(解出来对不上原文)
2283
- if (audio.toString('base64') !== encoded)
2284
- throw new Error('音频不是合法 base64');
2285
- if (audio.length > SPEECH_MAX_BYTES)
2286
- throw new Error('录音太大了');
2287
- seconds = validateWave(audio, SPEECH_MAX_SECONDS);
2288
- }
2289
- catch (error) {
2290
- sendJson(res, 200, { ok: false, code: 'audio', error: String(error?.message ?? error) });
2291
- return;
2292
- }
2293
- const catalog = speechCatalog();
2294
- const providers = catalog.providers ?? [];
2295
- // 这个部署默认用的语言(识别服务里配的,auto = 自动判断);请求里带了就以请求为准
2296
- const catalogLanguage = String(catalog.selection?.language ?? '');
2297
- const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
2298
- const picked = providers.find((item) => item.id === wanted) ??
2299
- providers.find((item) => item.phase === 'ready' || item.phase === 'standby') ??
2300
- providers[0];
2301
- if (!picked) {
2302
- sendJson(res, 200, { ok: false, code: 'empty', error: '没有可用的识别器' });
2303
- return;
2304
- }
2305
- if (picked.phase !== 'ready' && picked.phase !== 'standby') {
2306
- sendJson(res, 200, {
2307
- ok: false,
2308
- code: 'unprepared',
2309
- providerId: picked.id,
2310
- phase: picked.phase,
2311
- error: `识别模型还没准备好(${picked.name}:${picked.phase})`,
2312
- catalog,
2313
- });
2314
- return;
2315
- }
2316
- // 客户端断开 / 用户取消 → 立刻掐掉识别(本机模型在跑推理,不能让它白跑)
2317
- const controller = new AbortController();
2318
- const timer = setTimeout(() => controller.abort(new Error('识别超时')), SPEECH_TIMEOUT_MS);
2319
- const onClose = () => controller.abort(new Error('客户端已断开'));
2320
- req.on('close', onClose);
2321
- const startedAt = Date.now();
2322
- try {
2323
- const language = typeof body.language === 'string' && body.language ? body.language : undefined;
2324
- const spec = service.resolve({
2325
- audio,
2326
- ...(wanted ? { providerId: wanted } : {}),
2327
- ...(language === undefined ? {} : { language }),
2328
- });
2329
- const transcript = await service.transcribe(spec, controller.signal);
2330
- const text = String(transcript?.text ?? '').trim();
2331
- if (!text) {
2332
- sendJson(res, 200, { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' });
2333
- return;
2334
- }
2335
- sendJson(res, 200, {
2336
- ok: true,
2337
- text,
2338
- providerId: picked.id,
2339
- providerName: picked.name,
2340
- language: language ?? catalogLanguage,
2341
- seconds,
2342
- elapsedMs: Date.now() - startedAt,
2343
- });
2344
- }
2345
- catch (error) {
2346
- const message = String(error?.message ?? error);
2347
- const aborted = error?.name === 'AbortError' || controller.signal.aborted;
2348
- // 超时/断开与"识别失败"分开报:前者是这一句没转成,后者是识别服务本身有问题
2349
- sendJson(res, 200, {
2350
- ok: false,
2351
- code: aborted ? 'aborted' : 'failed',
2352
- error: aborted ? `这次识别被打断了(${message})` : message,
2353
- });
2354
- }
2355
- finally {
2356
- clearTimeout(timer);
2357
- req.off('close', onClose);
2358
- }
2359
- })().catch((error) => {
2360
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2361
- });
2362
- };
2363
- const handleHistory = (req, res) => {
2364
- void (async () => {
2365
- await loadSettings();
2366
- await loadHistory();
2367
- const url = new URL(req.url ?? '/', 'http://localhost');
2368
- if (req.method === 'DELETE') {
2369
- // 删除一条(?key=)或清空(?all=1)。
2370
- // 返回被删条目的**快照**,客户端据此做"撤销"(撤销就是把它原样 POST 回来)。
2371
- const all = url.searchParams.get('all') === '1';
2372
- if (all) {
2373
- const removed = Array.from(history.values());
2374
- history.clear();
2375
- scheduleHistoryWrite();
2376
- sendJson(res, 200, { ok: true, removed: removed.length, entries: removed.map(historyMeta) });
2377
- return;
2378
- }
2379
- const key = url.searchParams.get('key') ?? '';
2380
- if (!key) {
2381
- sendJson(res, 400, { ok: false, error: '缺少 key' });
2382
- return;
2383
- }
2384
- const entry = history.get(key);
2385
- if (!entry) {
2386
- sendJson(res, 200, { ok: true, removed: 0, entry: null });
2387
- return;
2388
- }
2389
- history.delete(key);
2390
- scheduleHistoryWrite();
2391
- sendJson(res, 200, { ok: true, removed: 1, entry });
2392
- return;
2393
- }
2394
- if (req.method === 'POST' && url.searchParams.get('restore') === '1') {
2395
- // 撤销删除:把客户端手里的快照原样写回(含 at/pinned,恢复原来的位置与标记)
2396
- const raw = await readBody(req, 2_000_000);
2397
- let body = {};
2398
- try {
2399
- body = raw ? JSON.parse(raw) : {};
2400
- }
2401
- catch {
2402
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
2403
- return;
2404
- }
2405
- const entry = body.entry;
2406
- if (!entry || typeof entry.key !== 'string' || !entry.key) {
2407
- sendJson(res, 400, { ok: false, error: '缺少 entry' });
2408
- return;
2409
- }
2410
- history.delete(entry.key);
2411
- history.set(entry.key, entry);
2412
- scheduleHistoryWrite();
2413
- sendJson(res, 200, { ok: true, size: history.size });
2414
- return;
2415
- }
2416
- if (req.method === 'POST') {
2417
- const raw = await readBody(req, 2_000_000);
2418
- let body = {};
2419
- try {
2420
- body = raw ? JSON.parse(raw) : {};
2421
- }
2422
- catch {
2423
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
2424
- return;
2425
- }
2426
- const size = saveHistoryEntry({
2427
- key: typeof body?.key === 'string' ? body.key : '',
2428
- text: typeof body?.text === 'string' ? body.text : '',
2429
- context: typeof body?.context === 'string' ? body.context : '',
2430
- label: typeof body?.label === 'string' ? body.label : '',
2431
- parts: (body?.parts ?? {}),
2432
- turns: (Array.isArray(body?.turns) ? body.turns : []),
2433
- toolDigest: typeof body?.toolDigest === 'string' ? body.toolDigest : '',
2434
- pinned: body?.pinned === true,
2435
- });
2436
- sendJson(res, 200, { ok: true, size });
2437
- return;
2438
- }
2439
- const key = url.searchParams.get('key');
2440
- if (key) {
2441
- const entry = history.get(key);
2442
- sendJson(res, 200, { ok: true, entry: entry ?? null });
2443
- return;
2444
- }
2445
- const entries = [...history.values()].sort((a, b) => b.at - a.at).map(historyMeta);
2446
- sendJson(res, 200, { ok: true, entries });
2447
- })().catch((error) => {
2448
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2449
- });
2450
- };
2451
- /** GET /models:给「模型」菜单用(带每个模型支持的推理等级;60 秒缓存)。 */
2452
- async function handleModels(req, res) {
2453
- await loadLeaky(); // 不 await 的话,这条路由在读"关档会泄漏"的清单时文件还没载入(实测报空)
2454
- const url = new URL(req.url ?? '/', 'http://localhost');
2455
- const force = url.searchParams.get('refresh') === '1';
2456
- let items = [];
2457
- let error = '';
2458
- try {
2459
- items = await listModelChoices(force);
2460
- }
2461
- catch (thrown) {
2462
- error = String(thrown?.message ?? thrown);
2463
- }
2464
- // 会话级:设置页的「跟随主会话」要显示**当前会话**在用的模型
2465
- let modelsSessionId = '';
2466
- try {
2467
- modelsSessionId = url.searchParams.get('sessionId') ?? '';
2468
- }
2469
- catch {
2470
- /* 解析不了就当没带 */
2471
- }
2472
- const current = resolveRoute(undefined, await sessionModelOf(modelsSessionId));
2473
- sendJson(res, 200, {
2474
- ok: true,
2475
- current,
2476
- cached: Date.now() - modelCache.at < MODEL_CACHE_MS,
2477
- ...(error ? { error } : {}),
2478
- models: items,
2479
- // 进程内学到的"关档会把思考写进正文"的模型(客户端据此在菜单里标注;重启后由客户端本地记忆兜着)
2480
- leakyOff: Array.from(leakyOnOff),
2481
- // 前端按 provider+model 匹配"当前档"
2482
- stages: {
2483
- chat: config.chatReasoningEffort || config.reasoningEffort,
2484
- translation: config.translationReasoningEffort || config.reasoningEffort,
2485
- detail: config.reasoningEffort,
2486
- },
2487
- });
2488
- }
2489
- /**
2490
- * 设置页的读写口。
2491
- *
2492
- * GET → { groups, values, defaults }:spec 一并下发,客户端只负责画
2493
- * POST → { values: {...} }:归一 → 立刻生效(改内存)→ 防抖落盘
2494
- * POST { reset: true } :清空覆盖,回到 cordis.patch.yml / 出厂默认
2495
- *
2496
- * 为什么不是"写回 cordis.patch.yml":那是装配清单,
2497
- * 让 UI 去改它既要人肉重启、又可能把 YAML 结构写坏;这里落在插件自己的目录,改完即时生效。
2498
- */
2499
- const handleSettings = (req, res) => {
2500
- void (async () => {
2501
- await loadSettings();
2502
- if (req.method === 'POST') {
2503
- const raw = await readBody(req, 200_000);
2504
- let body = {};
2505
- try {
2506
- body = raw ? JSON.parse(raw) : {};
2507
- }
2508
- catch {
2509
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
2510
- return;
2511
- }
2512
- if (body.reset === true) {
2513
- settingsOverrides = {};
2514
- applyOverrides(configDefaults);
2515
- scheduleSettingsWrite();
2516
- sendJson(res, 200, { ok: true, values: currentSettings(), rejected: [] });
2517
- return;
2518
- }
2519
- const result = normalizeSettingsPatch(body.values);
2520
- if (Object.keys(result.values).length === 0 && result.rejected.length > 0) {
2521
- sendJson(res, 400, { ok: false, error: '没有可写入的字段', rejected: result.rejected });
2522
- return;
2523
- }
2524
- // 合并而不是覆盖:只发一个键的补丁不该把其它改过的项抹掉
2525
- settingsOverrides = { ...settingsOverrides, ...result.values };
2526
- applyOverrides(result.values);
2527
- scheduleSettingsWrite();
2528
- sendJson(res, 200, { ok: true, values: currentSettings(), rejected: result.rejected });
2529
- return;
2530
- }
2531
- sendJson(res, 200, { ok: true, ...buildSettingsPayload(currentSettings(), configDefaults) });
2532
- })().catch((error) => {
2533
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2534
- });
2535
- };
2536
- /**
2537
- * 极轻的"这一轮会用哪个模型"查询。
2538
- *
2539
- * GET /selection-explain/api/route?sessionId=…[&chat=1]
2540
- *
2541
- * 为什么单开一条:设置页要**实时**显示当前会话在用的模型,
2542
- * 而 /models 会去列 provider 与模型(42 个,还要端点发现)、/ping 要枚举工具表并探 DNS ——
2543
- * 两者都不适合按秒轮询。这条只做一次会话模型解析 + 一次 resolveRoute。
2544
- *
2545
- * 也是唯一走 `sessionModelOf(…, true)`(短缓存)的地方:客户端在小窗开着时每 2 秒问一次,
2546
- * 不能每拍都重放一次会话日志(见 sessionModelCache 的注释)。
2547
- */
2548
- const handleRoute = (req, res) => {
2549
- void (async () => {
2550
- await loadSettings();
2551
- let sessionId = '';
2552
- let chat = false;
2553
- try {
2554
- const url = new URL(req.url ?? '/', 'http://localhost');
2555
- sessionId = url.searchParams.get('sessionId') ?? '';
2556
- chat = url.searchParams.get('chat') === '1';
2557
- }
2558
- catch {
2559
- /* 解析不了就当没带 */
2560
- }
2561
- const route = resolveRoute(undefined, await sessionModelOf(sessionId, true), chat);
2562
- sendJson(res, 200, {
2563
- ok: true,
2564
- provider: route?.provider ?? '',
2565
- model: route?.model ?? '',
2566
- // 是不是"跟首轮/次轮同一个":客户端据此把追问下拉显示成「跟随首轮 / 次轮」
2567
- chatFollows: true,
2568
- });
2569
- })().catch((error) => {
2570
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2571
- });
2572
- };
2573
- const handlePing = (req, res) => {
2574
- const sessionQuery = ctx.get('sessionQuery');
2575
- // 可以带 ?sessionId=xxx:工具是**按会话作用域**注册的,不带 id 只能看到全局视图(里面没有 web_search)
2576
- let pingSessionId = '';
2577
- try {
2578
- pingSessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? '';
2579
- }
2580
- catch {
2581
- /* 解析不了就当没带 */
2582
- }
2583
- // 小窗能调哪些工具:白名单是配置,实际放行还要看本机能不能抓网页(见下)。
2584
- // 用 async 包一层:探测 DNS 是异步的,出错也要给出结构化响应(和 history 路由同一写法)。
2585
- void (async () => {
2586
- // 先把用户改过的设置套上:下面报的每一条都必须是**当前生效**的值
2587
- await loadSettings();
2588
- const whitelist = config.toolNames
2589
- .split(',')
2590
- .map((item) => item.trim())
2591
- .filter((item) => item.length > 0);
2592
- const agents = ctx.get('agents');
2593
- // 复刻 handleAnalyze 的作用域回退:本会话 agent 取不到就用任一活跃 agent
2594
- let pingAgent = pingSessionId ? agents?.get?.(pingSessionId) : undefined;
2595
- let pingScope = pingAgent ? 'session' : '';
2596
- if (!pingAgent) {
2597
- pingAgent = agents?.list?.()?.[0];
2598
- pingScope = pingAgent ? 'fallback' : 'global';
2599
- }
2600
- let visible = [];
2601
- let visibleError = '';
2602
- let toolInfo = [];
2603
- let toolNames = [];
2604
- let sessionToolCount = 0;
2605
- try {
2606
- // 必须带着 registry 调(`const f = registry.schemas; f(...)` 会把 this 丢掉 → 拿到空表)
2607
- const registry = ctx.get('tools');
2608
- const all = registry?.schemas?.(pingAgent) ?? [];
2609
- sessionToolCount = all.length;
2610
- visible = all.map((schema) => schema.name).filter((toolName) => whitelist.includes(toolName));
2611
- // 诊断用:会话里这几个工具到底是哪个插件提供的(描述前 70 字能认出来)
2612
- toolInfo = all.map((schema) => ({
2613
- name: schema.name,
2614
- desc: String(schema.description ?? '').replace(/\s+/g, ' ').slice(0, 70),
2615
- }));
2616
- toolNames = all.map((schema) => schema.name);
2617
- }
2618
- catch (error) {
2619
- visibleError = String(error?.message ?? error);
2620
- }
2621
- // 本机抓不了网页时 web_fetch 会被摘掉(探针缓存 5 分钟,这里复用同一个)
2622
- let fetchBlocked = '';
2623
- if (whitelist.includes('web_fetch')) {
2624
- const probe = await webFetchUsable();
2625
- if (!probe.usable)
2626
- fetchBlocked = probe.sample;
2627
- }
2628
- sendJson(res, 200, {
2629
- ok: true,
2630
- plugin: name,
2631
- route: resolveRoute(undefined, await sessionModelOf(pingSessionId)),
2632
- reasoningEffort: config.reasoningEffort,
2633
- modelsRoute: `${API_PREFIX}/models`,
2634
- history: { maxEntries: config.historyMaxEntries, file: historyFile() },
2635
- // 三档分开报:一眼看出"首轮为什么快、详解为什么慢"
2636
- reasoningEffortByStage: {
2637
- translation: config.translationReasoningEffort,
2638
- detail: config.reasoningEffort,
2639
- chat: config.chatReasoningEffort,
2640
- },
2641
- // 小窗能用哪些工具:whitelist=配置允许,offered=这一轮真会交给模型的
2642
- tools: {
2643
- enabled: config.tools && config.maxToolRounds > 0,
2644
- whitelist,
2645
- offered: visible.filter((toolName) => !(toolName === 'web_fetch' && fetchBlocked)),
2646
- // 文件类工具的读取边界:'' = 会话工作目录,'*' = 不限制
2647
- readRoot: config.toolReadRoot === '*' ? '(不限)' : config.toolReadRoot || '(会话工作目录)',
2648
- visible: visibleError ? '读取工具表失败:' + visibleError : visible.length,
2649
- // 备用联网工具是否启用(没装 free-search 时会补上 web_search)
2650
- fallback: config.fallbackToolNames,
2651
- preferredSearchPresent: visible.includes('advanced_search') || visible.includes('platform_search'),
2652
- toolInfo,
2653
- toolNames,
2654
- // 这个会话一共能看到多少工具(放行的是白名单里那几个,其余一律不可用)
2655
- sessionTools: sessionToolCount,
2656
- scope: pingScope,
2657
- maxRounds: config.maxToolRounds,
2658
- fetchBlocked,
2659
- },
2660
- sessionContext: config.sessionContext && typeof sessionQuery?.readSurface === 'function',
2661
- // 侧边栏 HTML 预览里的划词桥开关:客户端据此决定要不要往预览帧注入桥脚本
2662
- bridgeSidebarPreview: config.bridgeSidebarPreview,
2663
- // 悬浮胶囊的两个偏好:跟着 ping 一起下发,省得客户端为它单开一次请求
2664
- pill: { enabled: config.pillEnabled, idleMs: config.pillIdleMs },
2665
- limits: {
2666
- maxSelectionChars: config.maxSelectionChars,
2667
- maxContextChars: config.maxContextChars,
2668
- sessionContextMaxMessages: config.sessionContextMaxMessages,
2669
- sessionContextFastMessages: config.sessionContextFastMessages,
2670
- sessionContextMaxChars: config.sessionContextMaxChars,
2671
- quoteContextRounds: config.quoteContextRounds,
2672
- quoteContextMaxChars: config.quoteContextMaxChars,
2673
- },
2674
- });
2675
- })().catch((error) => {
2676
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
2677
- });
2678
- };
2679
- /** 解析一个会话的工作目录(同项目 = 同 cwd)。 */
2680
- const resolveSessionCwd = async (sessionId) => {
2681
- if (!sessionId)
2682
- return undefined;
2683
- const sessions = ctx.get('sessions');
2684
- const live = sessions?.get?.(sessionId)?.header?.cwd;
2685
- if (live)
2686
- return live;
2687
- const sessionQuery = ctx.get('sessionQuery');
2688
- try {
2689
- const log = await sessionQuery?.readSession?.(sessionId);
2690
- return log?.session?.cwd;
2691
- }
2692
- catch {
2693
- return undefined;
2694
- }
2695
- };
2696
- /**
2697
- * 把划词解读小窗「升格」成正式会话(建在来源会话同一个项目下):
2698
- * ① 建会话 → ② 注入上下文消息(选中文字/所在位置/上下文片段,生产者自有 kind 的 context 消息)
2699
- * → ③ 把首轮解读写成助手消息 → ④ 把追问逐轮写成真实的 user/assistant 历史消息
2700
- * → ⑤ 送一条短开场消息让 agent 接手(这样整段历史才会落盘)。
2701
- * 这样新会话里看到的就是一段真实的多轮对话,而不是一大坨打包文本。
2702
- * live 会话对象拿不到时,退回「打包成一条消息」的老路径,保证升格不会整体失败。
2703
- */
2704
- const handlePromote = async (req, res) => {
2705
- if (req.method !== 'POST') {
2706
- sendJson(res, 405, { error: '只支持 POST' });
2707
- return;
2708
- }
2709
- let body;
2710
- try {
2711
- body = JSON.parse(await readBody(req, BODY_LIMIT));
2712
- }
2713
- catch (error) {
2714
- sendJson(res, 400, { error: `请求体解析失败:${String(error?.message ?? error)}` });
2715
- return;
2716
- }
2717
- const text = typeof body.text === 'string' ? body.text.trim() : '';
2718
- if (!text) {
2719
- sendJson(res, 400, { error: '缺少 text(选中文字)' });
2720
- return;
2721
- }
2722
- const controller = ctx.get('sessionController');
2723
- if (typeof controller?.create !== 'function' || typeof controller?.prompt !== 'function') {
2724
- sendJson(res, 503, { error: '当前部署不支持创建会话(sessionController 不可用)' });
2725
- return;
2726
- }
2727
- const sourceSessionId = sessionIdOf(body);
2728
- const cwd = await resolveSessionCwd(sourceSessionId);
2729
- // 会话在侧栏里的「项目分组」= workspace 的 sessionIds(按 canonical cwd 索引)。
2730
- // 只传 cwd 不够:还要带上 workspaceId,并在建好后把它挂到该 workspace 上(双保险)。
2731
- const registry = ctx.get('workspaceRegistry');
2732
- let workspaceId;
2733
- if (cwd) {
2734
- try {
2735
- const workspace = await registry?.resolveByPath?.(cwd);
2736
- if (typeof workspace?.id === 'string')
2737
- workspaceId = workspace.id;
2738
- }
2739
- catch (error) {
2740
- ctx.logger?.warn?.(`[${name}] 解析 workspace 失败(${cwd}):${String(error?.message ?? error)}`);
2741
- }
2742
- }
2743
- try {
2744
- // 注意:session.create 的 workspaceId 与 cwd 互斥(同时传会报
2745
- // "session.create accepts workspaceId or cwd, not both")——有 workspace 就只传它
2746
- const created = await controller.create(workspaceId ? { workspaceId } : cwd ? { cwd } : {});
2747
- const newSessionId = typeof created?.sessionId === 'string' ? created.sessionId : '';
2748
- if (!newSessionId) {
2749
- sendJson(res, 500, { error: '创建会话失败:未返回会话 id' });
2750
- return;
2751
- }
2752
- // 兜底:若 cwd 索引还没把它算进该 workspace,显式 attach 一次
2753
- if (workspaceId) {
2754
- try {
2755
- const workspace = registry?.get?.(workspaceId);
2756
- if (workspace && !(workspace.sessionIds ?? []).includes(newSessionId)) {
2757
- await workspace.attachSession?.(newSessionId);
2758
- }
2759
- }
2760
- catch (error) {
2761
- ctx.logger?.warn?.(`[${name}] 挂载会话到 workspace 失败:${String(error?.message ?? error)}`);
2762
- }
2763
- }
2764
- const title = `划词解读:${text.replace(/\s+/g, ' ').slice(0, 24)}`;
2765
- try {
2766
- await controller.rename?.({ sessionId: newSessionId, title });
2767
- }
2768
- catch {
2769
- /* 标题失败不影响升格 */
2770
- }
2771
- const label = clampText(body.label, 80) || '页面内容';
2772
- const contextText = clampText(body.context, config.maxContextChars) || '(未记录)';
2773
- const translation = clampText(body.translation, 8000);
2774
- const detail = clampText(body.detail, 12000);
2775
- const turns = (Array.isArray(body.turns) ? body.turns : [])
2776
- .slice(-20)
2777
- .map((turn) => turn)
2778
- .filter((turn) => turn.seed !== true && typeof turn.text === 'string' && turn.text.trim().length > 0)
2779
- .map((turn) => ({ role: turn.role === 'assistant' ? 'assistant' : 'user', text: turn.text.trim() }));
2780
- const live = ctx.get('sessions')?.get?.(newSessionId);
2781
- let seeded = false;
2782
- if (live && typeof live.append === 'function') {
2783
- const stamp = Date.now().toString(36);
2784
- /**
2785
- * 把一轮写进日志:turn/start → step/start → 消息 → step/end → turn/end。
2786
- * ⚠️ 必须带这层骨架:客户端会话视图是按 turn/step 事件给消息定位的,
2787
- * 裸写 user/message、assistant/message 会因为拿不到坐标而不显示成历史对话。
2788
- */
2789
- // 轮号从 1000 起:客户端按「轮号」分组(Map<number>),而新建会话里 agent 的第一轮
2790
- // 一定从 1 开始(phase.turn 初始为 0),所以历史轮必须避开低位号段,
2791
- // 否则历史的 turn 1 会和真实的 turn 1 并成同一组、看起来"历史没进去"。
2792
- // 同时这也是 resume 语义安全的:resume 时 phase.turn = lastTurn + 1 = 1000+N+1,仍不与历史重号。
2793
- let turnNo = 1000;
2794
- const appendTurn = (userEvent, assistantText) => {
2795
- turnNo += 1;
2796
- const turn = turnNo;
2797
- live.append?.('turn/start', { turn });
2798
- live.append?.('step/start', { turn, step: 1 });
2799
- if (userEvent)
2800
- live.append?.('user/message', userEvent, { surfaceOp: 'append' });
2801
- live.append?.('assistant/message', {
2802
- turn,
2803
- step: 1,
2804
- stream: [],
2805
- message: {
2806
- id: `sel-a-${stamp}-${turn}`,
2807
- role: 'assistant',
2808
- source: { kind: 'model', provider: 'dsh-selection-explain', model: '划词解读小窗' },
2809
- content: [{ type: 'text', text: assistantText }],
2810
- },
2811
- }, { surfaceOp: 'append' });
2812
- live.append?.('step/end', { turn, step: 1 });
2813
- live.append?.('turn/end', { turn, reason: { kind: 'completed' } });
2814
- };
2815
- // 第 1 轮:上下文消息(生产者自有的 source.kind + snapshot 呈现)+ 首轮解读
2816
- // ⚠️ kind 必须是 PLUGIN_SOURCE_KIND(`plugin:<插件名>`):会话格式 v4 起不再
2817
- // 接受退役的 `plugin` 包装(`kind: 'plugin'` 加一个 `plugin` 字段),否则整场升格
2818
- // 会死在 "format v4 message requires a producer-owned source kind" 上(见文件头常量注释)。
2819
- const contextBody = [
2820
- `选中文字:${text}`,
2821
- `所在位置:${label}`,
2822
- `来源:划词解读小窗${sourceSessionId ? `(来源会话 ${sourceSessionId})` : ''}`,
2823
- '',
2824
- '选中处的上下文片段:',
2825
- contextText,
2826
- ].join('\n');
2827
- const explanation = ['翻译', translation || '(未记录)', '', '详解', detail || '(未记录)'].join('\n');
2828
- appendTurn({
2829
- id: `sel-ctx-${stamp}`,
2830
- role: 'user',
2831
- source: {
2832
- kind: PLUGIN_SOURCE_KIND,
2833
- form: 'snapshot',
2834
- sections: [
2835
- { name: '划词解读 · 选中文字', text },
2836
- { name: '划词解读 · 所在位置', text: label },
2837
- { name: '划词解读 · 上下文片段', text: contextText },
2838
- ],
2839
- },
2840
- content: [{ type: 'text', text: contextBody }],
2841
- }, explanation);
2842
- // 后续轮:小窗里的追问逐轮写成真实问答
2843
- for (let i = 0; i < turns.length; i += 1) {
2844
- const turn = turns[i];
2845
- if (!turn)
2846
- continue;
2847
- if (turn.role === 'user') {
2848
- appendTurn({
2849
- id: `sel-u-${stamp}-${i}`,
2850
- role: 'user',
2851
- source: { kind: 'user' },
2852
- content: [{ type: 'text', text: turn.text }],
2853
- }, turns[i + 1] && turns[i + 1]?.role === 'assistant' ? (turns[i + 1]?.text ?? '') : '(未记录)');
2854
- i += 1; // 助手回答已随本轮写入
2855
- }
2856
- else {
2857
- // 落单的助手消息(历史里没有对应提问):单独成一轮
2858
- appendTurn(null, turn.text);
2859
- }
2860
- }
2861
- seeded = true;
2862
- }
2863
- // ⑤ 开场消息:让 agent 接手(也是整段历史落盘的触发点)
2864
- const opener = seeded
2865
- ? '(本会话由划词解读小窗升格而来,以上是这段文字的上下文与我们的讨论)请用一句话确认,然后等我继续提问。'
2866
- : [
2867
- '【划词解读 · 升格为会话】',
2868
- '下面这段文字来自一次划词解读小窗;上下文、结论与小窗里的追问一并附上,作为本次会话的既有背景。',
2869
- '',
2870
- '■ 选中文字',
2871
- text,
2872
- '',
2873
- '■ 所在位置',
2874
- label,
2875
- '',
2876
- '■ 选中处的上下文片段',
2877
- contextText,
2878
- '',
2879
- '■ 解读 · 翻译',
2880
- translation || '(未记录)',
2881
- '',
2882
- '■ 解读 · 详解',
2883
- detail || '(未记录)',
2884
- ...(turns.length > 0 ? ['', '■ 小窗里的追问', ...turns.map((t) => `${t.role === 'assistant' ? '答' : '我问'}:${t.text}`)] : []),
2885
- '',
2886
- '以上是背景,请把它作为本次会话的既有上下文;先不需要展开,等我继续提问。',
2887
- ].join('\n');
2888
- const promoteController = new AbortController();
2889
- await controller.prompt({
2890
- requestId: `sel-promote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2891
- sessionId: newSessionId,
2892
- mode: 'queue',
2893
- content: [{ type: 'text', text: opener }],
2894
- }, promoteController.signal);
2895
- sendJson(res, 200, {
2896
- ok: true,
2897
- sessionId: newSessionId,
2898
- cwd: cwd ?? null,
2899
- workspaceId: workspaceId ?? null,
2900
- mode: seeded ? 'history' : 'packed',
2901
- });
2902
- }
2903
- catch (error) {
2904
- sendJson(res, 500, { error: `升格失败:${String(error?.message ?? error)}` });
2905
- }
2906
- };
2907
- const handleAnalyze = async (req, res) => {
2908
- if (req.method !== 'POST') {
2909
- sendJson(res, 405, { error: '只支持 POST' });
2910
- return;
2911
- }
2912
- let body;
2913
- try {
2914
- body = JSON.parse(await readBody(req, BODY_LIMIT));
2915
- }
2916
- catch (error) {
2917
- sendJson(res, 400, { error: `请求体解析失败:${String(error?.message ?? error)}` });
2918
- return;
2919
- }
2920
- const text = typeof body.text === 'string' ? body.text.trim() : '';
2921
- if (text.length === 0) {
2922
- sendJson(res, 400, { error: '缺少 text(选中文字)' });
2923
- return;
2924
- }
2925
- if (text.length > config.maxSelectionChars) {
2926
- sendJson(res, 413, { error: `选中文字过长(${text.length} > ${config.maxSelectionChars} 字符)` });
2927
- return;
2928
- }
2929
- if (!allow()) {
2930
- sendJson(res, 429, { error: `请求过于频繁(上限 ${config.maxRequestsPerMinute}/分钟)` });
2931
- return;
2932
- }
2933
- const override = typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model
2934
- ? { provider: body.provider, model: body.model }
2935
- : undefined;
2936
- // 名字避开下面的 isChat(那里判的是"提示词用追问那套",此处判的是"模型用追问那个")
2937
- const isChatRound = typeof body.question === 'string' && body.question.trim().length > 0;
2938
- const route = resolveRoute(override, await sessionModelOf(sessionIdOf(body)), isChatRound);
2939
- if (!route) {
2940
- sendJson(res, 503, { error: '尚未确定模型路由:请先在设置里选择默认模型后重试' });
2941
- return;
2942
- }
2943
- const context = clampText(body.context, config.maxContextChars);
2944
- const label = clampText(body.label, 80);
2945
- // 页面标题/地址不写进提示词:模型会把它当成「选中文字里的英文」去翻译(实测把标题 DSH 译了一遍)
2946
- // 会话背景:从 sessionQuery 读取当前会话的模型界面(live 优先,含持久化会话)
2947
- let background = null;
2948
- if (config.sessionContext) {
2949
- const sessionId = sessionIdOf(body);
2950
- if (sessionId) {
2951
- // 背景预算按阶段:首轮只要翻译(8 条够定指代);详解与追问都是深度交互,吃满 24 条
2952
- const stage = typeof body.stage === 'string' ? body.stage : '';
2953
- const wantsFull = stage === 'detail' || typeof body.question === 'string';
2954
- background = await loadSessionBackground(sessionId, {
2955
- maxMessages: wantsFull ? config.sessionContextMaxMessages : config.sessionContextFastMessages,
2956
- maxChars: config.sessionContextMaxChars,
2957
- marker: text,
2958
- });
2959
- }
2960
- }
2961
- // 顺序即缓存策略:前缀必须“只增不变”才能复用 ——
2962
- // ① 会话背景(同一会话内基本不变)→ ② 固定说明 → ③ 动态尾部(选中文字/局部片段)
2963
- // 反过来写(动态在前、背景在后)会让缓存前缀在“选中文字”处就断掉:
2964
- // 实测同会话换一处选中,命中量从 5760 tokens 掉到 640~768。
2965
- const stablePrefix = [];
2966
- if (background && background.transcript) {
2967
- stablePrefix.push(`【会话背景】(按时间顺序;${background.marked ? '最后一条即选中文字所在的消息,已用 ▶ 标出,其后的对话未包含' : '未能定位选中位置,取的是最近的对话'})`, background.transcript, '', '上面是会话背景;下面是需要你解读的【选中文字】及其上下文。', '');
2968
- }
2969
- const userText = [
2970
- ...stablePrefix,
2971
- '【选中文字】',
2972
- text,
2973
- '',
2974
- `【所在位置】${label || '页面内容'}`,
2975
- '',
2976
- '【选中处的上下文片段】(选中部分用【】标出)',
2977
- context || '(未能取到上下文片段,请仅根据选中文字与会话背景作答)',
2978
- ].join('\n');
2979
- const effort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : config.reasoningEffort;
2980
- // 追问模式:客户端带 question 时,就是「就这段选中文字继续聊」
2981
- const question = typeof body.question === 'string' ? body.question.trim() : '';
2982
- const isChat = question.length > 0;
2983
- const historyMessages = [];
2984
- if (isChat) {
2985
- const rawHistory = Array.isArray(body.history) ? body.history : [];
2986
- for (const turn of rawHistory.slice(-20)) {
2987
- const item = turn;
2988
- const text = typeof item.text === 'string' ? item.text : '';
2989
- if (!text)
2990
- continue;
2991
- if (item.role === 'assistant') {
2992
- historyMessages.push(createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: route.provider, model: route.model } }));
2993
- }
2994
- else {
2995
- historyMessages.push(createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }));
2996
- }
2997
- }
2998
- }
2999
- const cacheKey = resultKeyOf(route.provider, route.model, sessionIdOf(body) + '|' + effort + '|' + (typeof body.stage === 'string' ? body.stage : 'translation'), background?.transcript ?? '', text, context);
3000
- const useResultCache = !isChat && config.resultCacheTtlMs > 0 && body.refresh !== true;
3001
- const cached = useResultCache ? resultCache.get(cacheKey) : undefined;
3002
- if (cached && Date.now() - cached.at < config.resultCacheTtlMs) {
3003
- res.writeHead(200, {
3004
- 'content-type': 'text/event-stream; charset=utf-8',
3005
- 'cache-control': 'no-store, no-transform',
3006
- connection: 'keep-alive',
3007
- 'x-accel-buffering': 'no',
3008
- });
3009
- sse(res, {
3010
- type: 'start',
3011
- provider: route.provider,
3012
- model: route.model,
3013
- cached: true,
3014
- context: { chars: context.length, label, session: background ? { chars: background.transcript.length, marked: background.marked } : null },
3015
- });
3016
- sse(res, { type: 'delta', text: cached.text });
3017
- sse(res, { type: 'done', chars: cached.text.length, cached: true });
3018
- res.end();
3019
- return;
3020
- }
3021
- res.writeHead(200, {
3022
- 'content-type': 'text/event-stream; charset=utf-8',
3023
- 'cache-control': 'no-store, no-transform',
3024
- connection: 'keep-alive',
3025
- 'x-accel-buffering': 'no',
3026
- });
3027
- const controller = new AbortController();
3028
- let finished = false;
3029
- /** 诊断:客户端(浏览器/探针)在流还没结束时就把连接关了 —— 会让上游被 abort,表现为"输出到一半停了"。 */
3030
- let clientClosedEarly = false;
3031
- const timer = setTimeout(() => controller.abort(), config.timeoutMs);
3032
- res.on('close', () => {
3033
- if (!finished) {
3034
- clientClosedEarly = true;
3035
- ctx.logger?.warn?.(`[${name}] 客户端提前断开(流未结束):已中止上游,避免继续消耗 token`);
3036
- controller.abort();
3037
- }
3038
- });
3039
- // 可用工具(白名单,默认只有联网搜索/抓取):交给模型自己决定要不要用。
3040
- // 注意工具是「按会话作用域」注册的(全局视图里看不到 web_search 等),
3041
- // 所以要拿该会话的 agent 当 scope 去查,并把同一个 agent 传给 execute。
3042
- const agents = ctx.get('agents');
3043
- const sessionId = sessionIdOf(body);
3044
- let agent = sessionId ? agents?.get?.(sessionId) : undefined;
3045
- let agentScope = agent ? 'session' : '';
3046
- if (!agent) {
3047
- // 回退:本会话还没起 agent(刚开的新会话/agent 已释放)时,用任一活跃 agent 当作用域。
3048
- // 不这么做的话,session-scoped 的 web_search/web_fetch 在全局视图里根本不可见 →
3049
- // 工具静默为空,模型还会把工具调用当正文吐出来(实测见过 <ds_safety_tool_call>)。
3050
- const live = agents?.list?.() ?? [];
3051
- if (live.length > 0) {
3052
- agent = live[0];
3053
- agentScope = 'fallback';
3054
- }
3055
- }
3056
- const toolSchemas = [];
3057
- /** 诊断:这一轮是不是走了"备用联网工具"(首选联网工具一个都不在)。 */
3058
- let toolFallbackUsed = false;
3059
- if (config.tools && config.maxToolRounds > 0) {
3060
- const registry = ctx.get('tools');
3061
- const split = (value) => value.split(',').map((item) => item.trim()).filter((item) => item.length > 0);
3062
- let visible = [];
3063
- try {
3064
- visible = registry?.schemas?.(agent) ?? [];
3065
- }
3066
- catch (error) {
3067
- ctx.logger?.warn?.(`[${name}] 读取工具表失败:${String(error?.message ?? error)}`);
3068
- }
3069
- const allowed = resolveToolNames(split(config.toolNames), split(config.fallbackToolNames), visible.map((schema) => schema.name));
3070
- toolFallbackUsed = !allowed.includes('advanced_search') && !allowed.includes('platform_search');
3071
- for (const schema of visible) {
3072
- if (allowed.includes(schema.name))
3073
- toolSchemas.push(schema);
3074
- }
3075
- }
3076
- // 本机抓不了网页(fake-IP 代理 / SSRF 防护)时,把 web_fetch 摘掉:
3077
- // 否则模型每次都要白跑一轮(5~15s)才拿到"resolves to a non-public IP address"。
3078
- let fetchBlockedSample = '';
3079
- if (toolSchemas.some((schema) => schema.name === 'web_fetch')) {
3080
- const probe = await webFetchUsable();
3081
- if (!probe.usable) {
3082
- fetchBlockedSample = probe.sample;
3083
- for (let i = toolSchemas.length - 1; i >= 0; i -= 1) {
3084
- if (toolSchemas[i]?.name === 'web_fetch')
3085
- toolSchemas.splice(i, 1);
3086
- }
3087
- ctx.logger?.warn?.(`[${name}] 网页抓取不可用(域名解析到 ${probe.sample},属保留地址段),已从工具表移除 web_fetch`);
3088
- }
3089
- }
3090
- // 文件类工具的边界:默认只允许会话工作目录;'*' = 不限制;配置了具体目录就用它。
3091
- // 拿不到工作目录时**不设限**(否则新会话里 read 直接全废),但会打日志说明。
3092
- const wantsFsTools = toolSchemas.some((schema) => FS_TOOL_NAMES.includes(schema.name));
3093
- let readRoot = '';
3094
- if (wantsFsTools) {
3095
- if (config.toolReadRoot === '*') {
3096
- readRoot = '';
3097
- }
3098
- else {
3099
- readRoot = config.toolReadRoot || (await resolveSessionCwd(sessionIdOf(body))) || '';
3100
- if (!readRoot)
3101
- ctx.logger?.warn?.(`[${name}] 拿不到会话工作目录,文件工具本次不设边界`);
3102
- }
3103
- }
3104
- const stage = typeof body.stage === 'string' ? body.stage : '';
3105
- // 选中文字是代码:首轮走「注释」提示词(代码块 + 逐句注释 + 小结)
3106
- const isCode = body.kind === 'code';
3107
- const basePrompt = isChat
3108
- ? CHAT_SYSTEM_PROMPT
3109
- : stage === 'detail'
3110
- ? DETAIL_PROMPT
3111
- : isCode
3112
- ? CODE_PROMPT
3113
- : TRANSLATION_PROMPT;
3114
- // 一个工具都没有时明确禁止伪造调用(模型会照着自己的习惯吐 <ds_safety_tool_call> 之类的格式)
3115
- // 正文里不许写"盘算过程"(模型在工具轮里很容易把"I should search again…"这类写进正文)
3116
- const narrateRule = '\n\n【正文只写结论】不要把思考、盘算、自查过程写进正文("我还得再搜一次""先看看拿到的标题""让我确认一下"这类一律不写);需要工具就直接调用,调用前后都不要解说。';
3117
- // 输出偏好(小窗开关):左档 Markdown / 右档 网页。
3118
- // 关档也要明确说"用 Markdown"——否则标签写着 Markdown、行为却可能自己给整页 HTML,开关就名不副实。
3119
- let webRule = '';
3120
- if (isChat && body.webAnswer !== true) {
3121
- webRule =
3122
- '\n\n【输出偏好:Markdown】用户把输出偏好设为 Markdown:默认用普通文字/Markdown 回答' +
3123
- '(标题、列表、表格、代码块都可以)。' +
3124
- '**这不禁止 HTML**——如果这个问题用一页 HTML 明显讲得更清楚,照样可以用网页回答;' +
3125
- '只是不要单纯为了"好看"就把简单问题做成页面。';
3126
- }
3127
- if (isChat && body.webAnswer === true) {
3128
- const skill = await loadDesignSkill();
3129
- webRule =
3130
- '\n\n【输出偏好:网页】这不是"一律用网页",而是**网页优先**:\n' +
3131
- '- **复杂问题**(需要对比、流程、层级、图表,或者文字答案会长到不好读)→ 直接给完整的单文件 HTML' +
3132
- '(放进 ```html 代码块),**页面本身就是答案**,不要再用文字复述一遍;\n' +
3133
- '- **简单问题**(一两段话能说清、不需要并列或图示)→ **仍然直接用文字回答**。' +
3134
- '不要为了用网页而用网页,也不要把简单问题包装成页面。\n' +
3135
- '拿不准时按"能不能用一小段话讲清"来判断:能,就文字。\n' +
3136
- '用网页时,页面会渲染在大约 **500px 宽**的小窗里,按下面的设计规范做:\n\n' +
3137
- skill +
3138
- (body.theme === 'dark'
3139
- ? '\n\n【配色:跟随宿主,当前是深色】宿主界面此刻是**深色模式**,页面必须是深色:' +
3140
- '背景用 #16181c~#1f2126 一档,正文 #e6e6e6,次级文字 #a1a1aa,分隔线 rgba(255,255,255,.12),' +
3141
- '卡片底色比页面底再亮一档。**不要白底黑字**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n'
3142
- : '\n\n【配色:跟随宿主,当前是浅色】宿主界面此刻是**浅色模式**,页面必须是浅色:' +
3143
- '背景用 #ffffff~#f7f7f8 一档,正文 #171717,次级文字 #6b7280,分隔线 rgba(0,0,0,.10),' +
3144
- '卡片底色与页面底拉开一档。**不要深色底**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n');
3145
- }
3146
- const fileRule = wantsFsTools
3147
- ? `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
3148
- : '';
3149
- const fetchRule = fetchBlockedSample
3150
- ? `\n\n【抓取网页不可用】本机域名解析到保留地址段(${fetchBlockedSample}),网页抓取会被安全策略拒绝:只做联网搜索,**不要尝试抓取具体网址**(省掉一次注定失败的往返)。`
3151
- : '';
3152
- const systemPrompt = (toolSchemas.length > 0
3153
- ? basePrompt
3154
- : `${basePrompt}\n\n【本轮没有可用工具】不要输出任何工具调用格式(如 <ds_safety_tool_call>、tool_calls、JSON 形式的调用),也不要声称你查过;需要实时/外部信息时直接说明你无法联网或无法读取,只基于已知信息作答。`) +
3155
- narrateRule +
3156
- webRule +
3157
- fileRule +
3158
- fetchRule;
3159
- // 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(chatReasoningEffort=high,要准)
3160
- // 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(默认 chatReasoningEffort=high)
3161
- // 但**追问档**允许请求覆盖(小窗的「推理等级(追问档)」控件靠这条生效):
3162
- // 首轮/详解仍按各自的配置走 —— 首轮故意用低档换速度,不该被追问档的设置带跑。
3163
- await loadLeaky();
3164
- let bodyEffort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : '';
3165
- // 这个模型在"关"档会把思考写进正文(实测过):别再发 none,直接抬到 low —— 既没泄漏,也不慢
3166
- const routeKey = `${route.provider}/${route.model}`;
3167
- if (isChat && bodyEffort === 'off' && leakyOnOff.has(routeKey)) {
3168
- bodyEffort = 'low';
3169
- sse(res, {
3170
- type: 'notice',
3171
- code: 'effort-clamped',
3172
- tier: 'off',
3173
- text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
3174
- });
3175
- }
3176
- let requestEffort = isChat
3177
- ? bodyEffort || config.chatReasoningEffort || effort
3178
- : stage === 'detail'
3179
- ? effort
3180
- : config.translationReasoningEffort || effort;
3181
- // 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
3182
- // 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
3183
- if (requestEffort === 'off' && noOffRoutes.has(routeKey))
3184
- requestEffort = 'low';
3185
- // 规范四格 → 这个模型真实声明的档位(每次请求现算;'' = 这个模型不支持推理,干脆不带档位)。
3186
- // 注意:leaky / noOff 两条守卫仍在**规范档位**这一层判断(它们记的是"关档"的语义),
3187
- // 映射放在最后一步。
3188
- const sentEffort = effortForRoute(routeKey, requestEffort);
3189
- // 「关」这一格在这个模型上到底是哪个真实档位:重试判定与泄漏检查都要按**真实档位**比
3190
- // (模型可能没有 off,或叫 none/minimal —— 那就不能拿字符串 'off' 去比)
3191
- const offSlotEffort = requestEffort === 'off' ? sentEffort : effortForRoute(routeKey, 'off');
3192
- sse(res, {
3193
- type: 'start',
3194
- provider: route.provider,
3195
- model: route.model,
3196
- mode: isChat ? 'chat' : stage === 'detail' ? 'detail' : isCode ? 'code' : 'translation',
3197
- stage: stage === 'detail' ? 'detail' : 'translation',
3198
- backgroundMessages: background ? background.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line)).length : 0,
3199
- effort: sentEffort,
3200
- tools: toolSchemas.map((schema) => schema.name),
3201
- toolScope: agentScope,
3202
- ...(toolFallbackUsed ? { toolFallbackUsed: true } : {}),
3203
- ...(wantsFsTools ? { readRoot: readRoot || '(不限)' } : {}),
3204
- ...(fetchBlockedSample ? { fetchBlocked: fetchBlockedSample } : {}),
3205
- context: {
3206
- chars: context.length,
3207
- label,
3208
- session: background
3209
- ? {
3210
- chars: background.transcript.length,
3211
- marked: background.marked,
3212
- // 背景短哈希:用于核对「前缀是否真的没变」(缓存命中的前提)
3213
- hash: createHash('sha1').update(background.transcript).digest('hex').slice(0, 10),
3214
- }
3215
- : null,
3216
- },
3217
- ...(body.debug === true
3218
- ? { debug: { system: systemPrompt, user: userText, sizes: { system: systemPrompt.length, user: userText.length } } }
3219
- : {}),
3220
- });
3221
- let chars = 0;
3222
- const baseMessage = createUserMessage({
3223
- source: { kind: 'user' },
3224
- content: [{ type: 'text', text: userText }],
3225
- });
3226
- // 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
3227
- const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : '';
3228
- // 顺序很重要:把"已经查到的结果"放**前面**、问题放**最后**。
3229
- // 反过来的话,模型最后读到的是原始检索结果,容易"整理资料"而不是回答问题
3230
- //(实测:问新闻却把上一次的检索结果当成要总结的对象)。
3231
- const questionText = toolDigest
3232
- ? `【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容;只在与本轮问题相关时才用)\n${toolDigest}\n\n【用户这一轮的问题】\n${question}`
3233
- : question;
3234
- const openingMessages = isChat
3235
- ? [...historyMessages, createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: questionText }] })]
3236
- : [];
3237
- try {
3238
- let errored = false;
3239
- let usage = null;
3240
- let answer = '';
3241
- const toolMessages = [];
3242
- let toolCalls = 0;
3243
- // 允许的工具轮 + 1 个**收尾轮**:收尾轮不带任何工具。
3244
- // 不加这一轮的话,模型每轮都调工具时循环直接结束,正文是空的——
3245
- // 用户看到的就是"转了半天,但没有任何回答"。
3246
- let errorMessage = '';
3247
- let errorCode;
3248
- const toolDigestLines = [];
3249
- /** 被残渣过滤器丢掉的字数:末尾那些"模型顺手又调了一次工具"的标记会让正文停在半句上。 */
3250
- /**
3251
- * 逐轮诊断(放进 done 事件):原始字数 / 过滤后字数 / 思考字数 / 残渣丢弃 / finish 原因 / 输出 tokens。
3252
- * 用来回答"到底是模型没吐、还是被我们的过滤器吃掉了、还是上游掐流"——不靠猜。
3253
- */
3254
- const roundDiag = [];
3255
- let conclusionInfo = {
3256
- tried: false,
3257
- chars: 0,
3258
- rounds: 0,
3259
- error: '',
3260
- };
3261
- const toolRounds = config.maxToolRounds;
3262
- // 档位被上游拒绝时(400/401/500)重试一次:清单里声明的档位是**建议值**,
3263
- // 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
3264
- //
3265
- // 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
3266
- // 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
3267
- // 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
3268
- //
3269
- // 2026-10-09 改:重试**不再"去掉档位"**,而是换一个没试过的档(见 nextEffortFor)。
3270
- // 老代码对非 off 档的重试是 `roundEffort = ''`(省略 reasoning),
3271
- // 而省略对"必须开推理"的端点就是关掉推理 —— 实测 20 次重试 0 次成功。
3272
- // 同时给两次机会:上游偶发失败约一半,两次能把成功率抬到 ~90%(代价是多一次往返)。
3273
- const EFFORT_RETRY_LIMIT = 2;
3274
- // 重试与"泄漏检查"都看**真正发出去**的那个档位(sentEffort,映射之后的值):
3275
- // 规范档位是"关"、但这个模型的关档叫 none/minimal 时,检查的必须是发出去的那个。
3276
- let effortRetries = sentEffort.length > 0 ? EFFORT_RETRY_LIMIT : 0;
3277
- const triedEfforts = new Set(sentEffort ? [sentEffort] : []);
3278
- const firstSentEffort = sentEffort;
3279
- let roundEffort = sentEffort;
3280
- for (let round = 0; round <= toolRounds; round += 1) {
3281
- const roundStartedAt = Date.now();
3282
- const wrapUp = round === toolRounds;
3283
- const stream = ctx.llm.stream({
3284
- provider: route.provider,
3285
- model: route.model,
3286
- // 带上会话 id:主会话 agent loop 一直传它,适配器会据此设置上游的会话头。
3287
- // 插件以前没传,结果是所有小窗请求共用静态会话头 —— 而中继是按会话跟踪的(实测长回答被掐)。
3288
- ...(sessionId ? { sessionId: sessionId } : {}),
3289
- system: undefined,
3290
- messages: [
3291
- // 第二参数(组装这段提示词的插件名):**新老版本签名不一样,两边都得能编译**——
3292
- // · npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的是 0.1.5-rc.3:`(text, plugin)` 两个必填
3293
- // (CI 就是拿这份类型做构建校验的,少传就 TS2554);
3294
- // · 本机 / 新版运行时(0.1.7-rc.x):只剩 `(text)`,多传的参数会被忽略。
3295
- // 所以统一走 systemMessageOf 这个宽松别名(见文件下方的定义),两边都调得动。
3296
- systemMessageOf(wrapUp
3297
- ? `${systemPrompt}\n\n【收尾】工具轮次已用完:直接基于已经拿到的信息给出结论,不要再请求工具,也不要复述查询过程。` +
3298
- '正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
3299
- : systemPrompt, name),
3300
- ...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
3301
- ],
3302
- // 工具**每轮都在请求里**(照主会话的做法):收尾轮以前故意不带工具"逼结论",
3303
- // 结果模型退到文本层、把工具调用标记写进正文(实测 wrapUp 轮 raw=726 → emitted=0)。
3304
- // 主会话从不摘工具,模型也就从不写这种标记 —— 这里与它保持一致。
3305
- ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3306
- ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
3307
- ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
3308
- ...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
3309
- signal: controller.signal,
3310
- });
3311
- const assembler = new BlockAssembler();
3312
- // 本轮吐出的正文字数:这一轮如果最后是要调工具,那这些字其实是"过程旁白"
3313
- // ("我还得再搜一次""先看看拿到的标题"这类),要被撤回,不能当成答案留在小窗里
3314
- let roundText = '';
3315
- // 有的模型把思考内联在正文里(<think>…</think>):拆出来走 thought,别当答案
3316
- // 每轮一个过滤器:未闭合的残渣块只在本轮内作废。
3317
- // 共用一个的话,某一轮吐了未闭合的 DSML(实测收尾轮很常见)会让 depth 一直 >0,
3318
- // 后面所有轮次的正文都被吞掉 —— 表现就是"只完成了工具查询,没有产出正文"(用户截图报的)。
3319
- const think = createThinkFilter();
3320
- let roundRaw = 0;
3321
- let roundEmitted = 0;
3322
- let roundThink = 0;
3323
- let roundFinish = '';
3324
- /** 诊断用:本轮最后若干分片的类型与大小(看上游是发了 finish 还是直接断流)。 */
3325
- const tailChunks = [];
3326
- const emit = (part) => {
3327
- if (part.think) {
3328
- roundThink += part.think.length;
3329
- sse(res, { type: 'thought', text: part.think });
3330
- }
3331
- // 残渣过滤放在 think 之后:先分出思考,再把正文里的工具调用标记丢掉
3332
- const text = part.text;
3333
- if (text) {
3334
- roundEmitted += text.length;
3335
- chars += text.length;
3336
- answer += text;
3337
- roundText += text;
3338
- sse(res, { type: 'delta', text: text });
3339
- }
3340
- };
3341
- for await (const chunk of stream) {
3342
- assembler.push(chunk);
3343
- if (body.debug === true) {
3344
- const size = chunk.type === 'text-delta' ? chunk.text.length : 0;
3345
- tailChunks.push(`${chunk.type}:${size}`);
3346
- if (tailChunks.length > 14)
3347
- tailChunks.shift();
3348
- }
3349
- if (chunk.type === 'text-delta') {
3350
- roundRaw += chunk.text.length;
3351
- emit(think.push(chunk.text));
3352
- }
3353
- else if (chunk.type === 'reasoning-delta') {
3354
- // 思考过程也透传:首字前那几秒如果只有转圈,用户会以为卡死了。
3355
- // 客户端只留尾部一小段当"正在想什么"的提示,不进正文。
3356
- sse(res, { type: 'thought', text: chunk.text });
3357
- }
3358
- else if (chunk.type === 'usage') {
3359
- usage = chunk.usage;
3360
- }
3361
- else if (chunk.type === 'finish') {
3362
- roundFinish = String(chunk.reason.kind);
3363
- if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
3364
- errored = true;
3365
- // 先只记下来:如果接下来要"换档重试",这条错误就不该发给用户
3366
- // (否则客户端先渲染一条失败提示,重试的正文就贴在失败提示后面了)
3367
- errorMessage =
3368
- chunk.reason.failure?.message ?? `模型调用${chunk.reason.kind === 'aborted' ? '被中断' : '失败'}`;
3369
- errorCode = chunk.reason.failure?.code;
3370
- }
3371
- }
3372
- }
3373
- emit(think.flush());
3374
- roundDiag.push({
3375
- round,
3376
- wrapUp,
3377
- ms: Date.now() - roundStartedAt,
3378
- raw: roundRaw,
3379
- emitted: roundEmitted,
3380
- think: roundThink,
3381
- finish: roundFinish || '(none)',
3382
- tools: assembler.blocks().filter((block) => block.type === 'tool-call').length,
3383
- ...(body.debug === true ? { tailChunks } : {}),
3384
- outTokens: usage?.outputTokens,
3385
- });
3386
- if (errored) {
3387
- // 一个字都没吐出来 → 可以安全重试**同一轮**(保守:已经吐出正文的失败不重试,
3388
- // 免得把半截答案丢掉)。重试换一个没试过的档位,而不是把档位去掉。
3389
- if (roundText.length === 0 && effortRetries > 0) {
3390
- const rejected = roundEffort;
3391
- const next = nextEffortFor(routeKey, rejected, triedEfforts);
3392
- if (next) {
3393
- effortRetries -= 1;
3394
- triedEfforts.add(next);
3395
- // 只有"我们发的这个档位确实不被接受"才值得记成事实:
3396
- // · 发的是关档 + 上游说"必须开推理" → 这个 route 不吃关档,记下来;
3397
- // · 发的是开着的档(low/high/max)+ 上游还在说"推理不能关" → **这句不是冲我们来的**
3398
- // (我们没关它),是上游偶发抽风(实测同档位 5/8 成功)。这种情况不记、不诊断,
3399
- // 只如实说"上游返回了错误,已换档重试"。
3400
- // "必须开推理"这句只有在我们发的是**「关」那一格对应的真实档位**时才算数
3401
- // (模型没有 off、关档落在 minimal/low 上时,字符串比较会漏判)
3402
- const rejectedOffSlot = rejected === offSlotEffort;
3403
- const tierIssue = rejectedOffSlot
3404
- ? looksLikeReasoningRequired(errorMessage)
3405
- : looksLikeUnsupportedTier(errorMessage);
3406
- if (rejectedOffSlot && tierIssue)
3407
- markNoOff(routeKey);
3408
- requestEffort = next;
3409
- roundEffort = next;
3410
- errored = false;
3411
- sse(res, {
3412
- type: 'notice',
3413
- code: tierIssue ? 'effort-rejected' : 'upstream-retry',
3414
- ...(tierIssue ? { tier: rejected } : {}),
3415
- text: tierIssue
3416
- ? rejectedOffSlot
3417
- ? `该模型不接受「关」档,已自动改用「${EFFORT_LABEL[next] ?? next}」档(以后不再尝试关档)`
3418
- : `「${EFFORT_LABEL[rejected] ?? rejected}」档该模型不支持,已改用「${EFFORT_LABEL[next] ?? next}」档重试`
3419
- : '上游这次返回了错误,已自动重试',
3420
- });
3421
- round -= 1;
3422
- continue;
3423
- }
3424
- }
3425
- sse(res, { type: 'error', message: errorMessage, code: errorCode });
3426
- break;
3427
- }
3428
- const blocks = assembler.blocks();
3429
- const calls = blocks.filter((block) => block.type === 'tool-call');
3430
- if (calls.length === 0) {
3431
- // 最终答案到手:如果发出去的是「关」那一格对应的真实档位,检查有没有把思考写进正文
3432
- if (isChat && firstSentEffort === offSlotEffort) {
3433
- const split = splitLeakedReasoning(answer);
3434
- if (split.prefix) {
3435
- markLeaky(routeKey);
3436
- answer = split.rest;
3437
- chars = answer.length;
3438
- sse(res, { type: 'strip', text: split.prefix });
3439
- sse(res, {
3440
- type: 'notice',
3441
- code: 'reasoning-leak',
3442
- // 报**真实档位**(模型可能没有 off,叫 none/minimal):客户端据此标对那个档位按钮
3443
- tier: firstSentEffort,
3444
- text: '该模型在「关」档会把思考写进正文,已撤回泄漏段;以后这一档会自动改用「低」',
3445
- });
3446
- }
3447
- else if (looksLikeLeakedReasoning(answer)) {
3448
- // 句式没认准(模型每次换说法)→ 这次不裁(怕误删正文),但记住它,下次自动抬档
3449
- markLeaky(routeKey);
3450
- sse(res, {
3451
- type: 'notice',
3452
- code: 'reasoning-leak',
3453
- tier: firstSentEffort,
3454
- text: '这个模型在「关」档会把思考写进正文;这一档以后自动改用「低」',
3455
- });
3456
- }
3457
- }
3458
- break;
3459
- }
3460
- // 既然这一轮要调工具,它刚才流出来的正文就是"过程旁白",不是答案:
3461
- // 让前端把这部分撤回去(顺带当成思考尾巴显示),免得"我怎么想的"混进小窗正文。
3462
- // 实测踩过:追问"搜索今天的新闻"时,模型在工具轮里先吐了一段英文盘算
3463
- // (I have sources with dates… Maybe I should do one more search…),
3464
- // 然后才给中文结论,这段英文被当正文渲染进了消息框。
3465
- if (roundText.length > 0) {
3466
- answer = answer.slice(0, Math.max(0, answer.length - roundText.length));
3467
- sse(res, { type: 'drop', chars: roundText.length, text: roundText.slice(-2000), round });
3468
- }
3469
- // 有的模型不带工具定义也会返回调用;执行端同样遵守开关和轮数上限。
3470
- if (wrapUp || toolSchemas.length === 0)
3471
- break;
3472
- // 执行工具→把结果回灌,再让模型继续(上限 maxToolRounds 轮)
3473
- toolMessages.push(createAssistantMessage({
3474
- content: blocks,
3475
- source: { provider: route.provider, model: route.model },
3476
- }));
3477
- for (const call of calls) {
3478
- if (call.type !== 'tool-call')
3479
- continue;
3480
- toolCalls += 1;
3481
- let parsed = {};
3482
- try {
3483
- parsed = call.arguments ? JSON.parse(call.arguments) : {};
3484
- }
3485
- catch {
3486
- parsed = {};
3487
- }
3488
- const label2 = describeToolCall(call.name, parsed);
3489
- const callId = String(call.id ?? `round${String(round)}-${String(toolCalls)}`);
3490
- sse(res, { type: 'tool', phase: 'start', callId, name: call.name, detail: label2, round });
3491
- const startedAt = Date.now();
3492
- // 参数名有讲究:read/write/edit 用 file_path,grep/glob 用 path。
3493
- // 只认 path 的话 read 的 file_path 会是 undefined → 边界检查被静默绕过(实测踩过)。
3494
- const rawPath = parsed?.file_path ?? parsed?.path;
3495
- const wantPath = typeof rawPath === 'string' ? rawPath : '';
3496
- const execution = !toolSchemas.some((schema) => schema.name === call.name)
3497
- ? { content: [{ type: 'text', text: `(调用已拒绝:${call.name} 不在本次允许的工具中)` }], isError: true }
3498
- : FS_TOOL_NAMES.includes(call.name) && !isInsideRoot(readRoot, wantPath)
3499
- ? {
3500
- content: [
3501
- {
3502
- type: 'text',
3503
- text: `(越界已拒绝:文件类工具只能访问当前项目目录${readRoot ? `(${readRoot})` : ''},请求的路径是 ${wantPath || '(未给)'})`,
3504
- },
3505
- ],
3506
- isError: true,
3507
- }
3508
- : await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent);
3509
- // 结果也回传:小窗里能看见"查了什么、命中什么、链接是什么",否则工具是个黑盒
3510
- const digest = digestToolResult(execution.content);
3511
- sse(res, {
3512
- type: 'tool',
3513
- phase: 'done',
3514
- callId,
3515
- name: call.name,
3516
- detail: label2,
3517
- round,
3518
- ok: !execution.isError,
3519
- ms: Date.now() - startedAt,
3520
- chars: digest.chars,
3521
- preview: digest.preview,
3522
- urls: digest.urls,
3523
- });
3524
- toolMessages.push(createToolResultMessage({ callId: call.id, content: execution.content, isError: execution.isError }));
3525
- // 结论轮要用:把"查了什么 + 拿到了什么"写成纯文本,避免重放工具调用链
3526
- toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`);
3527
- }
3528
- }
3529
- if (!errored && answer.trim().length === 0 && toolCalls > 0 && triedEfforts.size === 1) {
3530
- // 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
3531
- // 这里自己补结论轮:**不重放工具调用链**,而是把结果写成一段自包含的提示 ——
3532
- // 重放消息链的方式实测也不稳(模型面对 tool-call 历史仍可能继续吐工具调用)。
3533
- conclusionInfo.tried = true;
3534
- sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' });
3535
- const conclusionUser = [
3536
- // ⚠️ **必须带上用户这一轮问的是什么**:这一段是最终正文的来源,而它以前只有
3537
- // 「选区块 + 检索资料 + 请给出最终答案」—— 模型看不到问题,就只能顺着选中文字写。
3538
- // 实测症状:问"搜索今天的AI新闻。",正文却在解释选区那个词(如 diagLog / exit)。
3539
- // 特别是收尾轮也带上工具之后(照主会话的做法),正文**每次都**由这里产出,
3540
- // 缺了问题就等于每次都答非所问。
3541
- question ? `【用户这一轮的问题】(这就是你要回答的对象)\n${question}` : '',
3542
- '',
3543
- '【选中文字】(只是背景资料:问题需要它时才引用,不要拿它当回答的对象)',
3544
- userText,
3545
- '',
3546
- '【已经查到的资料】',
3547
- ...toolDigestLines.slice(0, 12),
3548
- '',
3549
- '请**直接回答上面那条「用户这一轮的问题」**:结论先行、必要处带来源链接。',
3550
- '不要复述选中文字,也不要解释它是什么 —— 除非问题问的就是它。',
3551
- '不要再请求工具,不要输出任何工具调用格式,也不要写"我先看看""让我核对一下"这类过程。',
3552
- ].join('\n');
3553
- // 先按当前档位试一次;空则**换一个档位**再试(同一档重发没意义)。
3554
- // 以前这里写死 `'off'`:对"必须开推理"的端点(noOffRoutes 里的那些)等于注定失败一轮 ——
3555
- // 用户看到的"工具查询完成了,但模型没有产出正文"就常有它一份。
3556
- const conclusionEfforts = [
3557
- roundEffort,
3558
- nextEffortFor(routeKey, roundEffort, new Set(roundEffort ? [roundEffort] : [])),
3559
- ].filter((id) => id.length > 0);
3560
- for (const attemptEffort of conclusionEfforts) {
3561
- if (answer.trim().length > 0)
3562
- break;
3563
- conclusionInfo.rounds += 1;
3564
- try {
3565
- const resume = ctx.llm.stream({
3566
- provider: route.provider,
3567
- model: route.model,
3568
- ...(sessionId ? { sessionId: sessionId } : {}),
3569
- system: undefined,
3570
- // 工具**依然带着**(照主会话:任何一轮都不摘工具)。
3571
- // 以前这一轮不带工具,模型要调工具时只能退回文本层 —— 那正是正文里出现
3572
- // DSML / ds_safety_tool_call 标记的唯一来源。带上之后它走协议层,正文就是正文。
3573
- ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3574
- messages: [
3575
- // 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
3576
- systemMessageOf(systemPrompt, name),
3577
- createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: conclusionUser }] }),
3578
- ],
3579
- ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
3580
- ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
3581
- ...(attemptEffort ? { reasoningEffort: ReasoningEffortId(attemptEffort) } : {}),
3582
- signal: controller.signal,
3583
- });
3584
- const thinkAgain = createThinkFilter();
3585
- for await (const chunk of resume) {
3586
- if (chunk.type === 'text-delta') {
3587
- const part = thinkAgain.push(chunk.text);
3588
- if (part.think)
3589
- sse(res, { type: 'thought', text: part.think });
3590
- const text = part.text;
3591
- if (text) {
3592
- chars += text.length;
3593
- answer += text;
3594
- conclusionInfo.chars += text.length;
3595
- sse(res, { type: 'delta', text: text });
3596
- }
3597
- }
3598
- else if (chunk.type === 'usage') {
3599
- usage = chunk.usage;
3600
- }
3601
- else if (chunk.type === 'finish') {
3602
- if (chunk.reason.kind === 'error') {
3603
- conclusionInfo.error = chunk.reason.failure?.message ?? '模型调用失败';
3604
- }
3605
- }
3606
- }
3607
- for (const tail of [thinkAgain.flush()])
3608
- if (tail.think)
3609
- sse(res, { type: 'thought', text: tail.think });
3610
- // 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
3611
- const leaked = splitLeakedReasoning(answer);
3612
- if (leaked.prefix) {
3613
- answer = leaked.rest;
3614
- chars = answer.length;
3615
- sse(res, { type: 'strip', text: leaked.prefix });
3616
- sse(res, { type: 'notice', code: 'reasoning-leak', text: '已去掉模型写进正文的核对过程' });
3617
- }
3618
- }
3619
- catch (error) {
3620
- conclusionInfo.error = String(error?.message ?? error);
3621
- ctx.logger?.warn?.(`[${name}] 结论轮失败:${conclusionInfo.error}`);
3622
- }
3623
- }
3624
- }
3625
- if (!errored && answer.trim().length === 0) {
3626
- // 兜底:连结论轮都没吐字时,至少给一句话(否则面板空白,看起来像"没返回结果")
3627
- const note = toolCalls > 0
3628
- ? '(工具查询完成了,但模型没有产出正文。可以再追问一句"直接说结论",我按已查到的结果回答。)'
3629
- : '(模型这次没有返回内容,可以点「重新生成」再试一次。)';
3630
- answer = note;
3631
- chars += note.length;
3632
- sse(res, { type: 'delta', text: note });
3633
- }
3634
- if (!errored) {
3635
- sse(res, {
3636
- type: 'done',
3637
- chars,
3638
- usage,
3639
- toolCalls,
3640
- // 诊断用:结论轮/续写各试了几次、拿到多少字、失败原因
3641
- ...(conclusionInfo.tried ? { conclusion: conclusionInfo } : {}),
3642
- ...(roundDiag.length > 0 ? { rounds: roundDiag } : {}),
3643
- clientClosedEarly,
3644
- });
3645
- if (useResultCache && answer.length > 0) {
3646
- resultCache.set(cacheKey, { at: Date.now(), text: answer });
3647
- if (resultCache.size > 200) {
3648
- const oldest = resultCache.keys().next().value;
3649
- if (oldest !== undefined)
3650
- resultCache.delete(oldest);
3651
- }
3652
- }
3653
- }
3654
- }
3655
- catch (error) {
3656
- const err = error;
3657
- const aborted = err?.name === 'AbortError';
3658
- sse(res, {
3659
- type: 'error',
3660
- message: aborted ? `调用超时或已取消(${config.timeoutMs}ms)` : String(err?.message ?? err),
3661
- });
3662
- }
3663
- finally {
3664
- finished = true;
3665
- clearTimeout(timer);
3666
- res.end();
3667
- }
3668
- };
3669
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/analyze`, handler: handleAnalyze }), `${name}: analyze route`);
3670
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/settings`, handler: handleSettings }), `${name}: settings route`);
3671
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/route`, handler: handleRoute }), `${name}: route route`);
3672
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/ping`, handler: handlePing }), `${name}: ping route`);
3673
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }), `${name}: history route`);
3674
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/panel`, handler: handlePanelSize }), `${name}: panel box route`);
3675
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }), `${name}: pill position route`);
3676
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/mic`, handler: handleMic }), `${name}: mic presence route`);
3677
- // 探针是懒启动的(第一次有人问才起),但**卸载时要收干净**:
3678
- // 否则插件热重载几次就会留下一串没人管的 mic-probe 进程。
3679
- ctx.effect(() => () => stopMicProbe(), `${name}: mic probe process`);
3680
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }), `${name}: models route`);
3681
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }), `${name}: promote route`);
3682
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/quote-context`, handler: handleQuoteContext }), `${name}: quote-context route`);
3683
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech`, handler: handleSpeech }), `${name}: speech catalog route`);
3684
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/transcribe`, handler: handleSpeechTranscribe }), `${name}: speech transcribe route`);
3685
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/prepare`, handler: handleSpeechPrepare }), `${name}: speech prepare route`);
3686
- ctx.logger?.info?.(`[${name}] 划词解读路由就绪:${API_PREFIX}/analyze`);
3687
- }
3688
- /**
3689
- * 纯函数内部件,仅供单测直接调用(Cordis 只用 default export,多这几个具名导出没有副作用)。
3690
- * 这三个都是"流式文本手术刀",靠真实请求验证会被模型的随机发挥带偏,必须能确定性单测。
3691
- */
1
+ /** Public DSH plugin entry; implementation lives in host/. */
2
+ export { name, inject, PLUGIN_SOURCE_KIND } from './host/constants.js';
3
+ export { Config } from './host/config.js';
4
+ export { apply } from './host/plugin.js';
5
+ export { validateWave } from './host/speech.js';
6
+ export { isInsideRoot } from './host/tools.js';
7
+ export { transcriptOf, quoteContextOf } from './host/context.js';
8
+ import { createThinkFilter, splitLeakedReasoning, looksLikeLeakedReasoning } from './host/filters.js';
9
+ import { resolveToolNames } from './host/tools.js';
10
+ import { looksLikeUnsupportedTier, looksLikeReasoningRequired, pickNextEffort, mapEffortToModel, effortMapOf } from './host/effort.js';
3692
11
  export const __internals = {
3693
12
  createThinkFilter,
3694
13
  splitLeakedReasoning,