@yfwu2020/dsh-selection-explain 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -12,9 +12,13 @@
12
12
  */
13
13
  import { createHash } from 'node:crypto'
14
14
  import { lookup } from 'node:dns/promises'
15
+ import { spawn } from 'node:child_process'
16
+ import type { ChildProcess } from 'node:child_process'
17
+ import { existsSync } from 'node:fs'
15
18
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
16
19
  import { homedir } from 'node:os'
17
20
  import { join, resolve, sep } from 'node:path'
21
+ import { createInterface } from 'node:readline'
18
22
  import { fileURLToPath } from 'node:url'
19
23
  import type { Context } from '@deepseek-ai/cordis'
20
24
  import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -64,6 +68,195 @@ export const inject = ['webServer', 'llm']
64
68
  /** 路由前缀。 */
65
69
  const API_PREFIX = '/selection-explain/api'
66
70
 
71
+ // ───────────────────────── 麦克风占用检测 ─────────────────────────
72
+ //
73
+ // 回答一个问题:**此刻是不是某个输入法正在语音输入?**(豆包输入法、微信输入法……)
74
+ //
75
+ // 客户端拿它做什么:用户选中一段文字、同时正在用输入法说话时,就在那段文字下方弹一个
76
+ // 临时输入框并自动聚焦 —— 输入法"上屏"的文字会直接落进那个框(IME 只认焦点元素,
77
+ // 所以我们不需要读它的转写结果),点发送就跳过翻译/详解直接进追问。
78
+ //
79
+ // 怎么知道的:macOS 14.4 起 CoreAudio 暴露了进程对象列表,逐进程可读
80
+ // `kAudioProcessPropertyIsRunningInput` —— 于是"谁在录"是一次精确查询,而不是猜。
81
+ // **读这个状态不需要麦克风 TCC 授权**(只有真正录音才需要),这点很关键:插件不该为了
82
+ // 一个"指示灯"去要用户的麦克风权限。
83
+ //
84
+ // 原生探针见 native/mic-probe.c(Node 没有 FFI,所以编个小二进制,构建脚本第 ⑤ 步产出)。
85
+ // 这里只负责:懒启动、解析它吐的 JSON 行、缓存最新状态。编不出来 / 非 macOS 时
86
+ // available:false —— 客户端据此完全不启用这个特性,不装作在检测。
87
+
88
+ interface MicProcess {
89
+ pid: number
90
+ bundleId: string
91
+ name: string
92
+ }
93
+
94
+ /** 最新一次探针状态(进程集合变了才更新)。 */
95
+ const micState = {
96
+ available: false,
97
+ capturing: false,
98
+ processes: [] as MicProcess[],
99
+ /** 不可用时的原因(not-macos / probe-missing / probe-failed / probe-exited)。 */
100
+ reason: '',
101
+ }
102
+
103
+ let micChild: ChildProcess | null = null
104
+ /** 只尝试启动一次:失败就认(避免每个请求都去 spawn 一遍)。 */
105
+ let micStarted = false
106
+ /** 最近一次有人来问探针的时间(用来判断"没人看了")。 */
107
+ let micLastUse = 0
108
+ /** "没人看就停"的检查定时器。 */
109
+ let micIdleTimer: NodeJS.Timeout | null = null
110
+
111
+ /**
112
+ * 没人来问之后,探针还留多久(毫秒)。
113
+ *
114
+ * 为什么不立刻停:页面在"点一下别处"时会瞬间停轮询,立刻杀进程会变成反复 spawn/kill。
115
+ * 留一段空转把这种抖动吸收掉;划词期间页面每 200ms 来问一次,探针自然不会到期。
116
+ *
117
+ * 为什么是 60 秒:让探针"热着"—— 你划词、看两眼、再划下一段,中间往往隔几十秒;
118
+ * 探针还在的话,第二次划词是**零启动开销**(冷启动要 ~80ms 才出第一行)。
119
+ * 代价是没人用之后进程多留一会儿,而它常驻只占 ~0.8% 单核,值。
120
+ * 实际停止发生在超时后的下一次检查,所以真实寿命是 60~65 秒(见 MIC_IDLE_CHECK_MS)。
121
+ */
122
+ const MIC_IDLE_MS = Number(process.env.DSH_SEL_MIC_IDLE_MS || 60000)
123
+ /** 空转检查的间隔(毫秒)。 */
124
+ const MIC_IDLE_CHECK_MS = Number(process.env.DSH_SEL_MIC_CHECK_MS || 5000)
125
+
126
+ /**
127
+ * 探针的采样间隔(毫秒)—— 决定它多久发现"麦克风被打开了"。
128
+ *
129
+ * 为什么是 125:实测常驻成本极低(250ms → 0.6% 单核,150ms → 0.7%),
130
+ * 而"按 Fn 到卡片弹出"的延迟里它是主要两项之一(另一项是页面轮询)。
131
+ * 调快一倍的代价约 0.1% 单核,换来发现延迟减半 —— 值。
132
+ * 原生探针会把区间夹在 50~5000ms 之间(见 native/mic-probe.c)。
133
+ */
134
+ const MIC_WATCH_MS = Number(process.env.DSH_SEL_MIC_WATCH_MS || 125)
135
+
136
+ /** 探针路径:lib/native/mic-probe(与编译产物 lib/index.js 同级;测试可注入假的)。 */
137
+ const MIC_PROBE =
138
+ process.env.DSH_SEL_MIC_PROBE || fileURLToPath(new URL('native/mic-probe', import.meta.url))
139
+
140
+ /**
141
+ * 懒启动探针(第一次有人来问才 spawn)。
142
+ *
143
+ * 为什么不加载就启动:插件装着的绝大多数时间没人划词,没必要常驻一个每秒读几次
144
+ * CoreAudio 的进程。第一次 GET /mic 时才起。
145
+ * 起过之后**也不是一直留着**:见 armMicIdleCheck —— 没人来问就停,下次再起。
146
+ */
147
+ function ensureMicProbe(): void {
148
+ if (micStarted) return
149
+ micStarted = true
150
+ if (process.platform !== 'darwin') {
151
+ micState.reason = 'not-macos'
152
+ return
153
+ }
154
+ if (!existsSync(MIC_PROBE)) {
155
+ micState.reason = 'probe-missing'
156
+ return
157
+ }
158
+ try {
159
+ const child = spawn(MIC_PROBE, ['--watch', String(MIC_WATCH_MS)], {
160
+ stdio: ['ignore', 'pipe', 'ignore'],
161
+ })
162
+ micChild = child
163
+ micState.available = true
164
+ micState.reason = ''
165
+ if (child.stdout) {
166
+ const lines = createInterface({ input: child.stdout })
167
+ lines.on('line', (line) => {
168
+ try {
169
+ const parsed = JSON.parse(line) as { capturing?: boolean; processes?: MicProcess[] }
170
+ micState.capturing = !!parsed.capturing
171
+ micState.processes = Array.isArray(parsed.processes) ? parsed.processes : []
172
+ } catch {
173
+ // 半行 / 坏行:丢掉,下一行会覆盖(探针每行都是完整状态)
174
+ }
175
+ })
176
+ }
177
+ child.on('error', () => {
178
+ micChild = null
179
+ micState.available = false
180
+ micState.capturing = false
181
+ micState.processes = []
182
+ micState.reason = 'probe-failed'
183
+ })
184
+ child.on('exit', () => {
185
+ micChild = null
186
+ micState.available = false
187
+ micState.capturing = false
188
+ micState.processes = []
189
+ micState.reason = 'probe-exited'
190
+ })
191
+ } catch {
192
+ micChild = null
193
+ micState.available = false
194
+ micState.reason = 'probe-failed'
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 排一次"没人看就停"的检查(幂等)。
200
+ *
201
+ * 页面的轮询只在"有选区 / 卡片开着"时跑,探针跟着它的节奏走就够了 ——
202
+ * 划词即启动,选区消失后再空转 MIC_IDLE_MS 就停,下次划词再起。
203
+ */
204
+ function armMicIdleCheck(): void {
205
+ if (micIdleTimer) return
206
+ micIdleTimer = setInterval(() => {
207
+ if (Date.now() - micLastUse < MIC_IDLE_MS) return
208
+ stopMicProbe()
209
+ }, MIC_IDLE_CHECK_MS)
210
+ if (typeof micIdleTimer.unref === 'function') micIdleTimer.unref()
211
+ }
212
+
213
+ /**
214
+ * 停掉探针(没人看了 / 插件卸载)。**下次有人来问会重新启动**。
215
+ *
216
+ * 注意 available 要跟着回落到 false:探针都停了,再报"正在录"就是过期数据 ——
217
+ * 客户端看到 !available 会停掉轮询(不假装),这正是我们要的。
218
+ */
219
+ function stopMicProbe(): void {
220
+ if (micIdleTimer) {
221
+ clearInterval(micIdleTimer)
222
+ micIdleTimer = null
223
+ }
224
+ // 允许下次重新启动(与"启动失败就认"不同:那是 spawn 失败,不该每个请求都重试)
225
+ micStarted = false
226
+ micState.available = false
227
+ micState.capturing = false
228
+ micState.processes = []
229
+ micState.reason = 'idle'
230
+ const child = micChild
231
+ micChild = null
232
+ if (!child) return
233
+ try {
234
+ child.kill()
235
+ } catch {
236
+ // 已经退了:无所谓
237
+ }
238
+ }
239
+
240
+ /**
241
+ * GET /selection-explain/api/mic
242
+ * → { ok, available, capturing, processes:[{pid,bundleId,name}], reason }
243
+ *
244
+ * 客户端只关心 `capturing` 和某个进程的 bundleId 是不是目标输入法;
245
+ * 一次把所有占用者给全,是因为"同时在录的还有别的 App"很常见(会议软件、录音工具),
246
+ * 只报第一个会让特性在那种情况下静默失效。
247
+ */
248
+ const handleMic = (req: IncomingMessage, res: ServerResponse): void => {
249
+ // 每次有人来问都续一次命:只要页面在轮询,探针就不会到期
250
+ micLastUse = Date.now()
251
+ ensureMicProbe()
252
+ armMicIdleCheck()
253
+ res.writeHead(200, {
254
+ 'content-type': 'application/json; charset=utf-8',
255
+ 'cache-control': 'no-store',
256
+ })
257
+ res.end(JSON.stringify({ ok: true, ...micState }))
258
+ }
259
+
67
260
  /** 单次请求体上限(字节)。 */
68
261
  const BODY_LIMIT = 512 * 1024
69
262
 
@@ -150,6 +343,25 @@ export interface Config {
150
343
  * 默认 10 秒:短于这个数会显得"刚看一眼就没了",长了又失去"收起来不挡视线"的意义。
151
344
  */
152
345
  pillIdleMs: number
346
+ /**
347
+ * 开麦结束、**一个字都没说**时,直接收起临时输入框。
348
+ *
349
+ * 默认开(2026-10-09 用户指定):等于替用户按了 Esc(带关机动画)。
350
+ * 关掉则回到"卡片留着" —— 一个字没说时可以接着手动打字。
351
+ *
352
+ * 注意"结束"不是"松手那一瞬间":输入法在松手后还会做一次**智能整理**(改写输入框内容),
353
+ * 所以真正动作要等到文字定稿(见客户端的 VOICE_SETTLE_MS)。否则会取消掉马上就要落进来的整理结果。
354
+ */
355
+ voiceCancelOnSilence: boolean
356
+ /**
357
+ * 开麦结束、**已经说出文字**时,直接发送(跳过手动点发送键)。
358
+ *
359
+ * 默认开(2026-10-09 用户指定):等于替用户点了发送键。
360
+ * 关掉则回到"先看一眼再点" —— 发送会开小窗、开始一次真实请求,不可逆。
361
+ *
362
+ * 同样要等文字定稿:松手那一刻输入法还没整理完,立刻发送发出去的是没整理的草稿。
363
+ */
364
+ voiceAutoSend: boolean
153
365
  }
154
366
 
155
367
  /** 配置 schema(缺省值即推荐值)。 */
@@ -219,6 +431,10 @@ export const Config = z.object({
219
431
  pillEnabled: z.boolean().default(true),
220
432
  /** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
221
433
  pillIdleMs: z.number().min(2000).max(600000).default(10000),
434
+ /** 空录音是否收起卡片(默认开,见 Config 注释)。 */
435
+ voiceCancelOnSilence: z.boolean().default(true),
436
+ /** 有字是否自动发送(默认开,见 Config 注释)。 */
437
+ voiceAutoSend: z.boolean().default(true),
222
438
  })
223
439
 
224
440
  /** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
@@ -273,6 +489,8 @@ interface AnalyzeBody {
273
489
  model?: unknown
274
490
  /** 网页模式(小窗里的开关):复杂问题默认用网页回答,并注入设计规范。 */
275
491
  webAnswer?: unknown
492
+ /** 客户端上报的当前深浅('dark' | 'light'):网页输出按它选配色。 */
493
+ theme?: unknown
276
494
  /** 追问模式下用户这一轮的问题。 */
277
495
  question?: unknown
278
496
  /** 追问模式下的历史轮次:[{ role: 'user' | 'assistant', text }]。 */
@@ -440,6 +658,245 @@ function looksLikeLeakedReasoning(text: string): boolean {
440
658
  return letters / head.length >= 0.5
441
659
  }
442
660
 
661
+ /**
662
+ * 设置页那四个格子(**规范档位**)。
663
+ *
664
+ * 它们不是发给模型的 id,而是一份"意图词汇":关(尽量不思考)/ 低 / 高 / 最高。
665
+ * 各 adapter 的档位词汇表并不统一 —— 本机 74 个模型上就数出七种:
666
+ * off>low>high>max / low>high>max / high>max / off>minimal>low>medium>high /
667
+ * off>low>medium>high>xhigh>max / low>medium>high>xhigh / minimal>low>medium>high>xhigh /
668
+ * max(只有一档)/ 一档都不声明(不支持推理)。
669
+ * 落到哪个真实档位由 `mapEffortToModel` 算(纯函数,有单测)。
670
+ */
671
+ const EFFORT_CANONICAL = ['off', 'low', 'high', 'max']
672
+
673
+ /**
674
+ * 档位名 → "思考深度"刻度(认 id,也认显示名;比较前会去掉空格/下划线/连字符并转小写)。
675
+ *
676
+ * 刻度只用来**比较深浅**,绝对数值没有意义。锚点取最常见的五档:off 0 / low 2 /
677
+ * medium 4 / high 6 / max 8,其余名字插在它们之间(minimal 在 off 与 low 之间、
678
+ * xhigh 在 high 与 max 之间)。认不出的名字不在这张表里 —— 由 `rankEfforts` 按位置插值。
679
+ */
680
+ const EFFORT_RANK: Record<string, number> = {
681
+ off: 0, none: 0, disabled: 0, no: 0, nocontext: 0,
682
+ minimal: 1, minimum: 1, least: 1, tiny: 1,
683
+ low: 2, light: 2,
684
+ mid: 3, moderate: 3,
685
+ medium: 4,
686
+ high: 6,
687
+ xhigh: 7, veryhigh: 7,
688
+ max: 8, maximum: 8, highest: 8,
689
+ ultra: 9, extreme: 9,
690
+ }
691
+
692
+ /** 归一化档位名:'X-High' / 'very high' / 'x_high' 都算一个东西。 */
693
+ function normEffortName(value: unknown): string {
694
+ return String(value ?? '')
695
+ .trim()
696
+ .toLowerCase()
697
+ .replace(/[\s_-]+/g, '')
698
+ }
699
+
700
+ /**
701
+ * 每个声明档位的刻度:认得的名字用表,不认识的按**位置**在已知邻居之间线性插值
702
+ * (两头都没有已知锚点时按序号平铺到 0..8)。最后钳成不递减 —— 各 adapter 的声明都是升序,
703
+ * 插值不该把这个顺序弄反。
704
+ */
705
+ function rankEfforts(declared: Array<{ id: string; name?: string }>): number[] {
706
+ const known = declared.map((item) => {
707
+ const byId = EFFORT_RANK[normEffortName(item.id)]
708
+ if (byId !== undefined) return byId
709
+ const byName = EFFORT_RANK[normEffortName(item.name)]
710
+ return byName === undefined ? null : byName
711
+ })
712
+ const n = declared.length
713
+ const ranks: number[] = []
714
+ for (let i = 0; i < n; i += 1) {
715
+ const hit = known[i]
716
+ if (hit !== null && hit !== undefined) {
717
+ ranks.push(hit)
718
+ continue
719
+ }
720
+ let left = -1
721
+ for (let l = i - 1; l >= 0; l -= 1) {
722
+ if (known[l] !== null && known[l] !== undefined) {
723
+ left = l
724
+ break
725
+ }
726
+ }
727
+ let right = -1
728
+ for (let r = i + 1; r < n; r += 1) {
729
+ if (known[r] !== null && known[r] !== undefined) {
730
+ right = r
731
+ break
732
+ }
733
+ }
734
+ const leftRank = left >= 0 ? (known[left] as number) : null
735
+ const rightRank = right >= 0 ? (known[right] as number) : null
736
+ if (leftRank !== null && rightRank !== null) {
737
+ ranks.push(leftRank + ((rightRank - leftRank) * (i - left)) / (right - left))
738
+ } else if (leftRank !== null) {
739
+ ranks.push(leftRank + (i - left))
740
+ } else if (rightRank !== null) {
741
+ ranks.push(rightRank - (right - i))
742
+ } else {
743
+ ranks.push(n > 1 ? (8 * i) / (n - 1) : 0)
744
+ }
745
+ }
746
+ for (let i = 1; i < n; i += 1) {
747
+ if (ranks[i]! < ranks[i - 1]!) ranks[i] = ranks[i - 1]!
748
+ }
749
+ return ranks
750
+ }
751
+
752
+ /**
753
+ * 规范四格 → 当前模型**真实声明**的档位(返回 '' 表示这个模型没有可发的档位)。
754
+ *
755
+ * 规则一条到底(对 `off>low>high>max` 这类 4 档模型恰好是恒等映射):
756
+ * · 关 → 声明里**最弱**的那档(模型没有 off 这类档时,就是它"能关到"的最小思考)
757
+ * · 最高 → 声明里**最强**的那档
758
+ * · 低/高 → 在整条刻度上取 1/3、2/3 处**最近的档**;候选**排除两端**(最弱那档留给「关」、
759
+ * 最强那档留给「最高」,四个格子于是各有落点),并保证 关 ≤ 低 ≤ 高 ≤ 最高 单调;
760
+ * 同样近时「低」取更浅的、「高」取更深的 —— 低要省、高要深。
761
+ *
762
+ * ⚠️ 这里**没有**任何按模型写死的表:输入只有"这个模型自己声明的档位列表"。
763
+ * 名字认得出来(off/low/high/max、minimal、xhigh…)就用名字的深度刻度;认不出来
764
+ * (别的 adapter 可能叫 a/b/c、tier1/tier2,或者干脆是数字)就按**它在声明里的位置**插值 ——
765
+ * 所以任何用户换任何模型都能落位,插件作者没见过那个模型也能映射。
766
+ * 为什么不让「低/高」也去抢最弱那档:那两个格子表达的是"开着思考、但别太深",
767
+ * 落到"关"那一档等于**把用户要的思考关掉**;只有「关」该动那一档。
768
+ * 只有一档可发时(如 kimi-k3 只有 max),四格都落到它 —— 模型没有别的选择。
769
+ *
770
+ * 返回 '' 表示"这个模型没有可发的档位"(声明为空),调用方据此**不带** reasoningEffort。
771
+ */
772
+ function mapEffortToModel(declared: Array<{ id: string; name?: string }>, canonical: string): string {
773
+ const list = (declared ?? []).filter((item) => item && typeof item.id === 'string' && item.id.length > 0)
774
+ if (list.length === 0) return ''
775
+ if (list.length === 1) return list[0]!.id
776
+ const slot = EFFORT_CANONICAL.includes(canonical) ? canonical : 'off'
777
+ const ranks = rankEfforts(list)
778
+ // 两端:按刻度取最浅/最深;同样深时**以声明顺序为准**(声明在前的那档更弱、在后的更强)。
779
+ // 不能只按下标取首尾:adapter 万一声明得不严格升序,按下标就选错了;也不能只按刻度取最大,
780
+ // 那样 `…>max>ultra` 这种"更强但刻度相同"的档会被 max 抢走。
781
+ let weakest = 0
782
+ let strongest = 0
783
+ for (let i = 1; i < list.length; i += 1) {
784
+ if (ranks[i]! < ranks[weakest]!) weakest = i
785
+ if (ranks[i]! >= ranks[strongest]!) strongest = i
786
+ }
787
+ if (slot === 'max') return list[strongest]!.id
788
+ if (slot !== 'low' && slot !== 'high') return list[weakest]!.id
789
+ const pool: number[] = []
790
+ for (let i = 0; i < list.length; i += 1) {
791
+ if (i !== weakest && i !== strongest) pool.push(i)
792
+ }
793
+ // 中间两档没人可选时(只有"最弱 + 最强"两档):都落到最强 —— 最弱那档留给「关」
794
+ if (pool.length === 0) return list[strongest]!.id
795
+ const span = ranks[strongest]! - ranks[weakest]!
796
+ const target = ranks[weakest]! + span * (slot === 'low' ? 1 / 3 : 2 / 3)
797
+ const preferDeeper = slot === 'high'
798
+ let best = pool[0]!
799
+ let bestDistance = Math.abs(ranks[best]! - target)
800
+ for (const index of pool) {
801
+ const distance = Math.abs(ranks[index]! - target)
802
+ const closer = distance < bestDistance
803
+ const tie = distance === bestDistance
804
+ if (closer || (tie && (preferDeeper ? ranks[index]! > ranks[best]! : ranks[index]! < ranks[best]!))) {
805
+ best = index
806
+ bestDistance = distance
807
+ }
808
+ }
809
+ return list[best]!.id
810
+ }
811
+
812
+ /**
813
+ * 一个模型的四格映射(设置页给用户看的就是它;请求时也走这份算)。
814
+ * 返回空串的格子表示"没有可发的档位"(模型不支持推理时四个格子都是空串)。
815
+ */
816
+ function effortMapOf(declared: Array<{ id: string; name?: string }>): Record<string, string> {
817
+ const out: Record<string, string> = {}
818
+ for (const slot of EFFORT_CANONICAL) out[slot] = mapEffortToModel(declared, slot)
819
+ return out
820
+ }
821
+
822
+ /**
823
+ * 清单里查不到这个模型时的兜底阶梯。
824
+ * 只列**最常见**的四档(与客户端 `FALLBACK_TIERS` 同一份):多出来的 `medium` 很多模型
825
+ * 并不声明,而 dsh-llm 对"声明里没有的档"是**直接抛错**(不 clamp)—— 兜底时不该去撞它。
826
+ */
827
+ const EFFORT_LADDER_FALLBACK = ['off', 'low', 'high', 'max']
828
+
829
+ /** 档位的中文名(notice 文案用,与客户端 TIER_LABEL 一致)。 */
830
+ const EFFORT_LABEL: Record<string, string> = { off: '关', low: '低', medium: '中', high: '高', max: '最大' }
831
+
832
+ /**
833
+ * 这条上游错误是不是"**我们发的这个档位**不被接受"。
834
+ *
835
+ * ⚠️ 判据必须窄。免费/预览端点的上游会**偶发**返回
836
+ * `Reasoning is mandatory for this endpoint and cannot be disabled.` ——
837
+ * 2026-10-09 实测(`opencode-go/step-5-preview-free`,交错发送排除时间漂移):
838
+ * · effort=low 成功 5/8;effort=high 成功 5/8 —— **同一档位时好时坏,档位不是变量**;
839
+ * · 对照模型 `longcat-2.5-preview-free` 6/6 成功(连 off 都收)。
840
+ * 所以只认"明确说这个档位无效"的措辞;"必须开推理"另有一条判据
841
+ * (见 looksLikeReasoningRequired,只在**我们发的是关档**时才算数)。
842
+ * 把偶发失败当成"档位不支持"会连锁出三个错:给用户一句错诊断、把失败永久记进
843
+ * noOffRoutes(以后再也不发这个档)、清掉用户选的档位。
844
+ */
845
+ function looksLikeUnsupportedTier(message: string): boolean {
846
+ const tier = '(?:reasoning|effort|thinking|推理|思考|档位?)'
847
+ const bad = '(?:unsupported|not\\s+support|invalid|unrecognized|不支持|无效|不认识)'
848
+ return (
849
+ new RegExp(`${bad}[^。\\n]{0,24}${tier}`, 'i').test(message) ||
850
+ new RegExp(`${tier}[^。\\n]{0,24}${bad}`, 'i').test(message)
851
+ )
852
+ }
853
+
854
+ /** 上游要求"必须开推理"——只有在**我们这次把推理关掉**(发的 off)时,这句才是冲我们来的。 */
855
+ function looksLikeReasoningRequired(message: string): boolean {
856
+ return (
857
+ /(?:reasoning|thinking|推理|思考)/i.test(message) &&
858
+ /(?:mandatory|required|must|cannot\s+be\s+disabled|必须|不能(?:被)?关闭|不可关闭)/i.test(message)
859
+ )
860
+ }
861
+
862
+ /**
863
+ * 换一个"没试过的"档位(重试用)。
864
+ *
865
+ * 为什么**绝不能省略档位**(老代码就是省略):省略 = 请求里不带 reasoning,
866
+ * 对"必须开推理"的端点等于把推理关掉 —— 实测 20 次这样的重试 **0 次成功**,
867
+ * 而同一档位首发成功率约 60%。省略比"发一个可能不被接受的档"更糟。
868
+ *
869
+ * 顺序:模型自己声明的 defaultEffort → 沿档位阶梯往上走一格(离"关"越远越安全)。
870
+ * 返回空串表示"没档位可换了",调用方就不重试。
871
+ */
872
+ function pickNextEffort(
873
+ declared: string[],
874
+ defaultEffort: string | null | undefined,
875
+ rejected: string,
876
+ tried: Set<string>,
877
+ ): string {
878
+ const usable = (id: string | null | undefined): id is string => !!id && id !== rejected && !tried.has(id)
879
+ if (usable(defaultEffort)) return defaultEffort
880
+ // 用**模型自己的声明顺序**(各 adapter 都是从最省到最深),"往上走一格"才落在它真实的下一档上。
881
+ // 老写法是"我方五档阶梯 ∩ 声明",看不见 minimal / xhigh / none 这类 id —— 而按刻度映射之后,
882
+ // 被拒的那个很可能正是这类 id(indexOf 得到 -1 → 退回最弱的那档,方向正好反了)。
883
+ const order = declared.length > 0 ? declared : EFFORT_LADDER_FALLBACK
884
+ const start = order.indexOf(rejected)
885
+ // ① 先往上走:离「关」越远越安全(不吃 off 的模型通常都吃更高的档)
886
+ for (let i = (start < 0 ? 0 : start + 1); i < order.length; i += 1) {
887
+ const id = order[i]
888
+ if (usable(id)) return id
889
+ }
890
+ // ② 再按"通常最稳"的顺序退(模型拒的是 max 时往上没得走,该退到 high,而不是退回 off ——
891
+ // off 恰恰是最容易被拒的那一档)
892
+ for (const id of ['high', 'medium', 'low', 'max', 'off']) {
893
+ if (order.includes(id) && usable(id)) return id
894
+ }
895
+ // ③ 兜底:声明里剩下的
896
+ for (const id of order) if (usable(id)) return id
897
+ return ''
898
+ }
899
+
443
900
  function splitLeakedReasoning(text: string): { prefix: string; rest: string } {
444
901
  const body = String(text || '')
445
902
  if (body.length < 40) return { prefix: '', rest: body }
@@ -469,108 +926,6 @@ function splitLeakedReasoning(text: string): { prefix: string; rest: string } {
469
926
  return { prefix: head, rest: tail }
470
927
  }
471
928
 
472
- /**
473
- * 正文里不该出现的"工具调用残渣"。
474
- *
475
- * 收尾轮**不带工具**(为了逼模型给结论),模型这时会把"还想再 read 一下"当正文吐出来,
476
- * 而且用的可能是两种格式之一:
477
- * ① 官方 safety 包装:`<ds_safety_tool_call>…</ds_safety_tool_call>`(客户端以前只认这个)
478
- * ② DeepSeek 原生 DSML:全角竖线包起来的 `tool_calls / invoke / parameter` 块(实测漏过正文)
479
- *
480
- * 这里在**流里**就把它滤掉:客户端连 delta 都收不到,存盘/升格的内容也是干净的。
481
- * (客户端那份 sanitizeToolResidue 留作兜底:旧缓存、历史回放这些路径不经过 host。)
482
- */
483
- /** 全角竖线(U+FF5C)×2 + DSML + ×2:DSML 标记本身,用它拼正则避免源码里出现字面量。 */
484
- const DSML_MARK = '\uFF5C\uFF5CDSML\uFF5C\uFF5C'
485
-
486
- /**
487
- * "残渣标签"的两种形态。
488
- *
489
- * DSML 这边**不能只认 `tool_calls`**:实测模型吐出来的是**逐标签式**的,而且 marker 与标签名之间
490
- * **有一个空格** —— `<MARK parameter name="limit" string="false">40</MARK parameter>`、
491
- * `<MARK invoke name="read">…</MARK invoke>`、`<MARK calls>…</MARK calls>`。
492
- * 我第一版只匹配 `<\uFF5CDSML\uFF5Ctool_calls>`(无空格)→ 一个都没命中,残渣照漏(实测 4 轮漏 3 轮)。
493
- */
494
- const DSML_TAG = new RegExp(`<\\/?\\s*${DSML_MARK}\\s*(?:tool_calls|calls|invoke|parameter)\\b[^>]*>`, 'g')
495
- const SAFETY_TAG = /<\/?\s*ds_safety_tool_call\s*>/gi
496
-
497
- /**
498
- * 把正文里的"工具调用残渣"整段丢掉。
499
- *
500
- * 为什么必须在流里做:收尾轮不带工具(为了逼模型给结论),模型会把"还想再调一次工具"当正文吐出来,
501
- * 客户端现有的 sanitizeToolResidue 只认官方 safety 包装,不认 DSML —— 用户看到的就是一段乱码标记。
502
- *
503
- * 算法:**按标签栈计数**。遇到开标签 depth+1、闭标签 depth-1;depth>0 期间的一切(含嵌套标签与参数值)
504
- * 全部丢弃;流在 depth>0 时结束 → 剩下的整段丢掉。这样三种实测形态(整块/缺包裹/单个标签)都能盖住。
505
- */
506
- function createResidueFilter(): { push: (text: string) => string; flush: () => string; dropped: () => number } {
507
- const HOLD = 32 // 标签可能被切开,没命中时留一小截尾巴再输出
508
- let buffer = ''
509
- let depth = 0
510
- let droppedChars = 0
511
- const scan = (text: string): RegExpExecArray | null => {
512
- DSML_TAG.lastIndex = 0
513
- const dsml = DSML_TAG.exec(text)
514
- SAFETY_TAG.lastIndex = 0
515
- const safety = SAFETY_TAG.exec(text)
516
- if (dsml && safety) return dsml.index <= safety.index ? dsml : safety
517
- return dsml ?? safety
518
- }
519
- /**
520
- * 该留多长的尾巴:除了固定 32 字,还要**精确扣住"半个标签"**。
521
- * 踩过的坑:DSML 标签本身有 40+ 字(`<MARK parameter name="include" string="true">`),
522
- * 只留 32 字时标签被切在中间 → 前半截被当正文发出去,后半截又不成标签 → 残渣漏进正文(实测漏过)。
523
- * 判据:末尾最后一个 '<' 之后没有 '>',就认为它是未完成的标签,从那个 '<' 起全部扣住。
524
- */
525
- const holdOf = (text: string): number => {
526
- const lastLt = text.lastIndexOf('<')
527
- if (lastLt >= 0 && text.indexOf('>', lastLt) < 0) return Math.max(HOLD, text.length - lastLt)
528
- return HOLD
529
- }
530
- const drain = (final: boolean): string => {
531
- let out = ''
532
- for (;;) {
533
- const hit = scan(buffer)
534
- if (!hit) {
535
- const hold = holdOf(buffer)
536
- if (depth > 0) {
537
- // 在残渣块内部:丢掉,只留足以认出闭合标签的尾巴
538
- if (final) buffer = ''
539
- else buffer = buffer.length > hold ? buffer.slice(-hold) : buffer
540
- break
541
- }
542
- if (final) {
543
- out += buffer
544
- buffer = ''
545
- } else if (buffer.length > hold) {
546
- out += buffer.slice(0, buffer.length - hold)
547
- buffer = buffer.slice(-hold)
548
- }
549
- break
550
- }
551
- if (depth === 0) out += buffer.slice(0, hit.index)
552
- const closing = hit[0].charAt(1) === '/'
553
- if (!closing) droppedChars += hit[0].length
554
- depth = closing ? Math.max(0, depth - 1) : depth + 1
555
- buffer = buffer.slice(hit.index + hit[0].length)
556
- }
557
- return out
558
- }
559
- return {
560
- push: (text: string) => {
561
- buffer += text
562
- return drain(false)
563
- },
564
- flush: () => {
565
- const out = drain(true)
566
- depth = 0 // 本轮结束就把状态归零:未闭合的残渣块不许影响下一轮
567
- buffer = ''
568
- return out
569
- },
570
- /** 本轮被丢掉了多少字(用来判断"末尾残渣被删掉后句子没写完")。 */
571
- dropped: () => droppedChars,
572
- }
573
- }
574
929
 
575
930
  function sendJson(res: ServerResponse, status: number, body: unknown): void {
576
931
  const text = JSON.stringify(body)
@@ -1211,6 +1566,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
1211
1566
  bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
1212
1567
  pillEnabled: rawConfig?.pillEnabled ?? true,
1213
1568
  pillIdleMs: rawConfig?.pillIdleMs ?? 10000,
1569
+ voiceCancelOnSilence: rawConfig?.voiceCancelOnSilence ?? true,
1570
+ voiceAutoSend: rawConfig?.voiceAutoSend ?? true,
1214
1571
  }
1215
1572
 
1216
1573
  /**
@@ -1317,6 +1674,18 @@ export function apply(ctx: Context, rawConfig: Config): void {
1317
1674
  name: string
1318
1675
  efforts: Array<{ id: string; name: string }>
1319
1676
  defaultEffort: string | null
1677
+ /**
1678
+ * 这个模型到底支不支持"思考强度":
1679
+ * true = 有推理档位(efforts 非空)
1680
+ * false = 明确不支持(dsh-llm 的 resolveModelInfo 说它没有 reasoning)
1681
+ * null = 没读到(还没加载 / 这次调用失败)→ 原样发规范档位,被拒时由换档重试兜
1682
+ */
1683
+ hasReasoning: boolean | null
1684
+ /**
1685
+ * 规范四格 → 这个模型**真实**的档位 id('' = 没有可发的档位)。
1686
+ * 由 `effortMapOf(model.efforts)` 实时算出,随清单一起下发 —— 设置页据此显示"这四格现在落到哪"。
1687
+ */
1688
+ effortMap: Record<string, string>
1320
1689
  }
1321
1690
  let modelCache: { at: number; items: ModelChoice[] } = { at: 0, items: [] }
1322
1691
  /** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
@@ -1339,12 +1708,20 @@ export function apply(ctx: Context, rawConfig: Config): void {
1339
1708
  try {
1340
1709
  const raw = await readFile(leakyFile(), 'utf8')
1341
1710
  const parsed = JSON.parse(raw) as { routes?: unknown; noOff?: unknown }
1342
- if (Array.isArray(parsed?.routes)) {
1343
- for (const item of parsed.routes) if (typeof item === 'string' && item) leakyOnOff.add(item)
1344
- }
1345
- if (Array.isArray(parsed?.noOff)) {
1346
- for (const item of parsed.noOff) if (typeof item === 'string' && item) noOffRoutes.add(item)
1711
+ let dropped = 0
1712
+ const accept = (item: unknown, into: Set<string>): void => {
1713
+ if (typeof item !== 'string' || !item) return
1714
+ // 落盘文件是**跨重启的经验**,但也可能被测试夹具写脏(实测有 p1/m-strong):
1715
+ // provider 不存在的条目直接丢掉,并顺手把文件写回干净版。
1716
+ if (!isRealProvider(item.split('/')[0] ?? '')) {
1717
+ dropped += 1
1718
+ return
1719
+ }
1720
+ into.add(item)
1347
1721
  }
1722
+ if (Array.isArray(parsed?.routes)) for (const item of parsed.routes) accept(item, leakyOnOff)
1723
+ if (Array.isArray(parsed?.noOff)) for (const item of parsed.noOff) accept(item, noOffRoutes)
1724
+ if (dropped > 0) saveLeaky()
1348
1725
  } catch {
1349
1726
  /* 没有这个文件是常态 */
1350
1727
  }
@@ -1357,9 +1734,30 @@ export function apply(ctx: Context, rawConfig: Config): void {
1357
1734
  })
1358
1735
  }
1359
1736
 
1737
+ /**
1738
+ * 这条 route 的 provider 是不是**这台机器上真有的**。
1739
+ *
1740
+ * 为什么落盘前要查一下:这两个集合会写进 `~/.dsh/selection-explain/leaky-off.json`,
1741
+ * 而请求体里的 provider 是可以伪造的(`resolveRoute` 在清单没加载出来时只做形状校验)。
1742
+ * 实测踩过:`test-client.mjs` 的夹具 provider `p1` 被写进了用户真实的落盘文件
1743
+ * (`p1/m-strong`、`p1/m-fast`)—— 之后再也没人清掉它。
1744
+ * 宁可不记(顶多多撞一次上游,反正有重试),也不要往用户的文件里写不存在的东西。
1745
+ */
1746
+ function isRealProvider(provider: string): boolean {
1747
+ if (!provider) return false
1748
+ try {
1749
+ const llm = ctx.get('llm') as { listProviders?: () => Array<{ id?: string }> } | undefined
1750
+ const list = llm?.listProviders?.() ?? []
1751
+ return list.some((item) => String(item?.id ?? '') === provider)
1752
+ } catch {
1753
+ return false
1754
+ }
1755
+ }
1756
+
1360
1757
  /** 记住一个"关档会泄漏"的模型(内存 + 落盘)。 */
1361
1758
  function markLeaky(route: string): void {
1362
1759
  if (leakyOnOff.has(route)) return
1760
+ if (!isRealProvider(route.split('/')[0] ?? '')) return
1363
1761
  leakyOnOff.add(route)
1364
1762
  if (leakyLoaded) saveLeaky()
1365
1763
  }
@@ -1367,9 +1765,49 @@ export function apply(ctx: Context, rawConfig: Config): void {
1367
1765
  /** 记住一个"不接受关档"的模型(内存 + 落盘):下次直接改用 low,不再先撞一次。 */
1368
1766
  function markNoOff(route: string): void {
1369
1767
  if (noOffRoutes.has(route)) return
1768
+ if (!isRealProvider(route.split('/')[0] ?? '')) return
1370
1769
  noOffRoutes.add(route)
1371
1770
  if (leakyLoaded) saveLeaky()
1372
1771
  }
1772
+
1773
+ /** 档位阶梯与判据都在模块层(纯函数,可单测):这里只把清单数据喂给它。 */
1774
+ function nextEffortFor(route: string, rejected: string, tried: Set<string>): string {
1775
+ const item = modelCache.items.find((entry) => `${entry.provider}/${entry.model}` === route)
1776
+ return pickNextEffort(
1777
+ (item?.efforts ?? []).map((effort) => effort.id),
1778
+ item?.defaultEffort,
1779
+ rejected,
1780
+ tried,
1781
+ )
1782
+ }
1783
+
1784
+ /**
1785
+ * 规范档位(设置页那四格)→ 这个 route **这次真要发**的档位('' = 不带 reasoningEffort)。
1786
+ *
1787
+ * 每次请求都按**当前模型的声明**现算(不缓存映射结果、不落盘):用户换模型、DSH 升级换
1788
+ * adapter 之后立刻跟着变。清单里查不到(还没加载过 / 那次调用失败)就原样发规范值 ——
1789
+ * 与改动前一致,被拒时由换档重试兜;同时后台预热一次清单,下一次请求就能映射上。
1790
+ * 模型明确不支持推理(hasReasoning === false)时不发档位:发了 dsh-llm 必拒。
1791
+ */
1792
+ function effortForRoute(route: string, canonical: string): string {
1793
+ const item = modelCache.items.find((entry) => `${entry.provider}/${entry.model}` === route)
1794
+ if (!item) {
1795
+ warmModelCache()
1796
+ return canonical
1797
+ }
1798
+ if (item.hasReasoning === false) return ''
1799
+ const mapped = item.effortMap?.[canonical]
1800
+ return mapped === undefined || mapped === '' ? canonical : mapped
1801
+ }
1802
+
1803
+ /** 清单预热:只在"真要用却还没读到"时拉一次,60 秒内不重复拉(失败也不打转)。 */
1804
+ let modelWarmAt = 0
1805
+ function warmModelCache(): void {
1806
+ if (modelCache.items.length > 0) return
1807
+ if (Date.now() - modelWarmAt < MODEL_CACHE_MS) return
1808
+ modelWarmAt = Date.now()
1809
+ void listModelChoices(false).catch(() => undefined)
1810
+ }
1373
1811
  const MODEL_CACHE_MS = 60_000
1374
1812
 
1375
1813
  async function listModelChoices(force = false): Promise<ModelChoice[]> {
@@ -1404,6 +1842,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
1404
1842
  if (!model?.id) continue
1405
1843
  let efforts: Array<{ id: string; name: string }> = []
1406
1844
  let defaultEffort: string | null = null
1845
+ // null = 没读到(调用失败 / 拿不到信息);true/false = 明确支持 / 不支持
1846
+ let hasReasoning: boolean | null = null
1407
1847
  try {
1408
1848
  // 两个方法签名不同,各自显式标注,避免联合类型把返回值退化成 any
1409
1849
  type Resolved = { reasoning?: { efforts?: Array<{ id?: string; name?: string }>; defaultEffort?: string } } | null
@@ -1413,11 +1853,15 @@ export function apply(ctx: Context, rawConfig: Config): void {
1413
1853
  } else if (typeof llm.resolveModel === 'function') {
1414
1854
  info = (await llm.resolveModel(provider.id, model.id)) as Resolved
1415
1855
  }
1416
- const list = info?.reasoning?.efforts ?? []
1417
- efforts = list
1418
- .filter((item) => typeof item?.id === 'string' && item.id.length > 0)
1419
- .map((item) => ({ id: String(item.id), name: String(item.name ?? item.id) }))
1420
- defaultEffort = typeof info?.reasoning?.defaultEffort === 'string' ? info.reasoning.defaultEffort : null
1856
+ if (info) {
1857
+ const list = info.reasoning?.efforts ?? []
1858
+ efforts = list
1859
+ .filter((item) => typeof item?.id === 'string' && item.id.length > 0)
1860
+ .map((item) => ({ id: String(item.id), name: String(item.name ?? item.id) }))
1861
+ defaultEffort = typeof info.reasoning?.defaultEffort === 'string' ? info.reasoning.defaultEffort : null
1862
+ // 声明了 reasoning 但档位列表为空 → 发任何档位 dsh-llm 都会拒(它自己也不接受空表)
1863
+ hasReasoning = efforts.length > 0
1864
+ }
1421
1865
  } catch {
1422
1866
  efforts = []
1423
1867
  }
@@ -1428,6 +1872,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
1428
1872
  name: String(model.name ?? model.id),
1429
1873
  efforts,
1430
1874
  defaultEffort,
1875
+ hasReasoning,
1876
+ effortMap: effortMapOf(efforts),
1431
1877
  })
1432
1878
  }
1433
1879
  }
@@ -3123,7 +3569,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
3123
3569
  '不要为了用网页而用网页,也不要把简单问题包装成页面。\n' +
3124
3570
  '拿不准时按"能不能用一小段话讲清"来判断:能,就文字。\n' +
3125
3571
  '用网页时,页面会渲染在大约 **500px 宽**的小窗里,按下面的设计规范做:\n\n' +
3126
- skill
3572
+ skill +
3573
+ (body.theme === 'dark'
3574
+ ? '\n\n【配色:跟随宿主,当前是深色】宿主界面此刻是**深色模式**,页面必须是深色:' +
3575
+ '背景用 #16181c~#1f2126 一档,正文 #e6e6e6,次级文字 #a1a1aa,分隔线 rgba(255,255,255,.12),' +
3576
+ '卡片底色比页面底再亮一档。**不要白底黑字**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n'
3577
+ : '\n\n【配色:跟随宿主,当前是浅色】宿主界面此刻是**浅色模式**,页面必须是浅色:' +
3578
+ '背景用 #ffffff~#f7f7f8 一档,正文 #171717,次级文字 #6b7280,分隔线 rgba(0,0,0,.10),' +
3579
+ '卡片底色与页面底拉开一档。**不要深色底**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n')
3127
3580
  }
3128
3581
  const fileRule = wantsFsTools
3129
3582
  ? `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
@@ -3164,6 +3617,13 @@ export function apply(ctx: Context, rawConfig: Config): void {
3164
3617
  // 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
3165
3618
  // 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
3166
3619
  if (requestEffort === 'off' && noOffRoutes.has(routeKey)) requestEffort = 'low'
3620
+ // 规范四格 → 这个模型真实声明的档位(每次请求现算;'' = 这个模型不支持推理,干脆不带档位)。
3621
+ // 注意:leaky / noOff 两条守卫仍在**规范档位**这一层判断(它们记的是"关档"的语义),
3622
+ // 映射放在最后一步。
3623
+ const sentEffort = effortForRoute(routeKey, requestEffort)
3624
+ // 「关」这一格在这个模型上到底是哪个真实档位:重试判定与泄漏检查都要按**真实档位**比
3625
+ // (模型可能没有 off,或叫 none/minimal —— 那就不能拿字符串 'off' 去比)
3626
+ const offSlotEffort = requestEffort === 'off' ? sentEffort : effortForRoute(routeKey, 'off')
3167
3627
 
3168
3628
  sse(res, {
3169
3629
  type: 'start',
@@ -3172,7 +3632,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
3172
3632
  mode: isChat ? 'chat' : stage === 'detail' ? 'detail' : isCode ? 'code' : 'translation',
3173
3633
  stage: stage === 'detail' ? 'detail' : 'translation',
3174
3634
  backgroundMessages: background ? background.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line)).length : 0,
3175
- effort: requestEffort,
3635
+ effort: sentEffort,
3176
3636
  tools: toolSchemas.map((schema) => schema.name),
3177
3637
  toolScope: agentScope,
3178
3638
  ...(toolFallbackUsed ? { toolFallbackUsed: true } : {}),
@@ -3201,8 +3661,11 @@ export function apply(ctx: Context, rawConfig: Config): void {
3201
3661
  })
3202
3662
  // 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
3203
3663
  const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : ''
3664
+ // 顺序很重要:把"已经查到的结果"放**前面**、问题放**最后**。
3665
+ // 反过来的话,模型最后读到的是原始检索结果,容易"整理资料"而不是回答问题
3666
+ //(实测:问新闻却把上一次的检索结果当成要总结的对象)。
3204
3667
  const questionText = toolDigest
3205
- ? `${question}\n\n【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容)\n${toolDigest}`
3668
+ ? `【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容;只在与本轮问题相关时才用)\n${toolDigest}\n\n【用户这一轮的问题】\n${question}`
3206
3669
  : question
3207
3670
  const openingMessages: Message[] = isChat
3208
3671
  ? [...historyMessages, createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: questionText }] })]
@@ -3220,7 +3683,6 @@ export function apply(ctx: Context, rawConfig: Config): void {
3220
3683
  let errorCode: string | undefined
3221
3684
  const toolDigestLines: string[] = []
3222
3685
  /** 被残渣过滤器丢掉的字数:末尾那些"模型顺手又调了一次工具"的标记会让正文停在半句上。 */
3223
- let residueDropped = 0
3224
3686
  /**
3225
3687
  * 逐轮诊断(放进 done 事件):原始字数 / 过滤后字数 / 思考字数 / 残渣丢弃 / finish 原因 / 输出 tokens。
3226
3688
  * 用来回答"到底是模型没吐、还是被我们的过滤器吃掉了、还是上游掐流"——不靠猜。
@@ -3239,9 +3701,18 @@ export function apply(ctx: Context, rawConfig: Config): void {
3239
3701
  // 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
3240
3702
  // 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
3241
3703
  // 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
3242
- const canRetryEffort = requestEffort.length > 0
3243
- let effortRetried = false
3244
- let roundEffort = requestEffort
3704
+ //
3705
+ // 2026-10-09 改:重试**不再"去掉档位"**,而是换一个没试过的档(见 nextEffortFor)。
3706
+ // 老代码对非 off 档的重试是 `roundEffort = ''`(省略 reasoning),
3707
+ // 而省略对"必须开推理"的端点就是关掉推理 —— 实测 20 次重试 0 次成功。
3708
+ // 同时给两次机会:上游偶发失败约一半,两次能把成功率抬到 ~90%(代价是多一次往返)。
3709
+ const EFFORT_RETRY_LIMIT = 2
3710
+ // 重试与"泄漏检查"都看**真正发出去**的那个档位(sentEffort,映射之后的值):
3711
+ // 规范档位是"关"、但这个模型的关档叫 none/minimal 时,检查的必须是发出去的那个。
3712
+ let effortRetries = sentEffort.length > 0 ? EFFORT_RETRY_LIMIT : 0
3713
+ const triedEfforts = new Set<string>(sentEffort ? [sentEffort] : [])
3714
+ const firstSentEffort = sentEffort
3715
+ let roundEffort = sentEffort
3245
3716
  for (let round = 0; round <= toolRounds; round += 1) {
3246
3717
  const roundStartedAt = Date.now()
3247
3718
  const wrapUp = round === toolRounds
@@ -3267,7 +3738,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
3267
3738
  ),
3268
3739
  ...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
3269
3740
  ],
3270
- ...(!wrapUp && toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3741
+ // 工具**每轮都在请求里**(照主会话的做法):收尾轮以前故意不带工具"逼结论",
3742
+ // 结果模型退到文本层、把工具调用标记写进正文(实测 wrapUp 轮 raw=726 → emitted=0)。
3743
+ // 主会话从不摘工具,模型也就从不写这种标记 —— 这里与它保持一致。
3744
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3271
3745
  ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
3272
3746
  ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
3273
3747
  ...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
@@ -3282,7 +3756,6 @@ export function apply(ctx: Context, rawConfig: Config): void {
3282
3756
  // 共用一个的话,某一轮吐了未闭合的 DSML(实测收尾轮很常见)会让 depth 一直 >0,
3283
3757
  // 后面所有轮次的正文都被吞掉 —— 表现就是"只完成了工具查询,没有产出正文"(用户截图报的)。
3284
3758
  const think = createThinkFilter()
3285
- const residue = createResidueFilter()
3286
3759
  let roundRaw = 0
3287
3760
  let roundEmitted = 0
3288
3761
  let roundThink = 0
@@ -3295,7 +3768,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
3295
3768
  sse(res, { type: 'thought', text: part.think })
3296
3769
  }
3297
3770
  // 残渣过滤放在 think 之后:先分出思考,再把正文里的工具调用标记丢掉
3298
- const text = part.text ? residue.push(part.text) : ''
3771
+ const text = part.text
3299
3772
  if (text) {
3300
3773
  roundEmitted += text.length
3301
3774
  chars += text.length
@@ -3324,7 +3797,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
3324
3797
  roundFinish = String(chunk.reason.kind)
3325
3798
  if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
3326
3799
  errored = true
3327
- // 先只记下来:如果接下来要"去掉档位重试",这条错误就不该发给用户
3800
+ // 先只记下来:如果接下来要"换档重试",这条错误就不该发给用户
3328
3801
  // (否则客户端先渲染一条失败提示,重试的正文就贴在失败提示后面了)
3329
3802
  errorMessage =
3330
3803
  chunk.reason.failure?.message ?? `模型调用${chunk.reason.kind === 'aborted' ? '被中断' : '失败'}`
@@ -3333,19 +3806,6 @@ export function apply(ctx: Context, rawConfig: Config): void {
3333
3806
  }
3334
3807
  }
3335
3808
  emit(think.flush())
3336
- // 注意:residue.flush() 吐出的正文**不能再走 emit()** —— emit 会把它再喂回同一个过滤器,
3337
- // 于是 HOLD=32 又扣住尾巴,而本轮到此结束 → 最后 32 字永远不会发出去(实测 raw−emitted 恒为 32)。
3338
- {
3339
- const tailText = residue.flush()
3340
- if (tailText) {
3341
- chars += tailText.length
3342
- answer += tailText
3343
- roundText += tailText
3344
- roundEmitted += tailText.length
3345
- sse(res, { type: 'delta', text: tailText })
3346
- }
3347
- }
3348
- residueDropped += residue.dropped()
3349
3809
  roundDiag.push({
3350
3810
  round,
3351
3811
  wrapUp,
@@ -3353,38 +3813,48 @@ export function apply(ctx: Context, rawConfig: Config): void {
3353
3813
  raw: roundRaw,
3354
3814
  emitted: roundEmitted,
3355
3815
  think: roundThink,
3356
- residueDropped: residue.dropped(),
3357
3816
  finish: roundFinish || '(none)',
3358
3817
  tools: assembler.blocks().filter((block) => block.type === 'tool-call').length,
3359
3818
  ...(body.debug === true ? { tailChunks } : {}),
3360
3819
  outTokens: (usage as { outputTokens?: number } | null)?.outputTokens,
3361
3820
  })
3362
3821
  if (errored) {
3363
- // 一个字都没吐出来 + 是显式档位导致的失败 → 去掉档位重试**同一轮**
3364
- // (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
3365
- if (canRetryEffort && !effortRetried && roundText.length === 0) {
3366
- effortRetried = true
3822
+ // 一个字都没吐出来 → 可以安全重试**同一轮**(保守:已经吐出正文的失败不重试,
3823
+ // 免得把半截答案丢掉)。重试换一个没试过的档位,而不是把档位去掉。
3824
+ if (roundText.length === 0 && effortRetries > 0) {
3367
3825
  const rejected = roundEffort
3368
- if (rejected === 'off') {
3369
- // "关"被拒:记下这个 route,改用 low(最接近关的可接受档位),并让重试也用 low。
3370
- // 只去掉档位(交给供应商默认)在有些模型上等于开了深度思考 —— 又慢又可能把思考写进正文。
3371
- markNoOff(routeKey)
3372
- requestEffort = 'low'
3373
- roundEffort = 'low'
3374
- } else {
3375
- roundEffort = ''
3826
+ const next = nextEffortFor(routeKey, rejected, triedEfforts)
3827
+ if (next) {
3828
+ effortRetries -= 1
3829
+ triedEfforts.add(next)
3830
+ // 只有"我们发的这个档位确实不被接受"才值得记成事实:
3831
+ // · 发的是关档 + 上游说"必须开推理" → 这个 route 不吃关档,记下来;
3832
+ // · 发的是开着的档(low/high/max)+ 上游还在说"推理不能关" → **这句不是冲我们来的**
3833
+ // (我们没关它),是上游偶发抽风(实测同档位 5/8 成功)。这种情况不记、不诊断,
3834
+ // 只如实说"上游返回了错误,已换档重试"。
3835
+ // "必须开推理"这句只有在我们发的是**「关」那一格对应的真实档位**时才算数
3836
+ // (模型没有 off、关档落在 minimal/low 上时,字符串比较会漏判)
3837
+ const rejectedOffSlot = rejected === offSlotEffort
3838
+ const tierIssue = rejectedOffSlot
3839
+ ? looksLikeReasoningRequired(errorMessage)
3840
+ : looksLikeUnsupportedTier(errorMessage)
3841
+ if (rejectedOffSlot && tierIssue) markNoOff(routeKey)
3842
+ requestEffort = next
3843
+ roundEffort = next
3844
+ errored = false
3845
+ sse(res, {
3846
+ type: 'notice',
3847
+ code: tierIssue ? 'effort-rejected' : 'upstream-retry',
3848
+ ...(tierIssue ? { tier: rejected } : {}),
3849
+ text: tierIssue
3850
+ ? rejectedOffSlot
3851
+ ? `该模型不接受「关」档,已自动改用「${EFFORT_LABEL[next] ?? next}」档(以后不再尝试关档)`
3852
+ : `「${EFFORT_LABEL[rejected] ?? rejected}」档该模型不支持,已改用「${EFFORT_LABEL[next] ?? next}」档重试`
3853
+ : '上游这次返回了错误,已自动重试',
3854
+ })
3855
+ round -= 1
3856
+ continue
3376
3857
  }
3377
- errored = false
3378
- sse(res, {
3379
- type: 'notice',
3380
- code: 'effort-rejected',
3381
- tier: rejected,
3382
- text: rejected === 'off'
3383
- ? '该模型不支持「关」档,已自动改用「低」档(以后不再尝试关档)'
3384
- : `「${rejected}」档位该模型不支持,已按默认档位重试`,
3385
- })
3386
- round -= 1
3387
- continue
3388
3858
  }
3389
3859
  sse(res, { type: 'error', message: errorMessage, code: errorCode })
3390
3860
  break
@@ -3392,8 +3862,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
3392
3862
  const blocks: ContentBlock[] = assembler.blocks()
3393
3863
  const calls = blocks.filter((block) => block.type === 'tool-call')
3394
3864
  if (calls.length === 0) {
3395
- // 最终答案到手:如果用的是 "关" 档,检查有没有把思考写进正文
3396
- if (isChat && requestEffort === 'off') {
3865
+ // 最终答案到手:如果发出去的是「关」那一格对应的真实档位,检查有没有把思考写进正文
3866
+ if (isChat && firstSentEffort === offSlotEffort) {
3397
3867
  const split = splitLeakedReasoning(answer)
3398
3868
  if (split.prefix) {
3399
3869
  markLeaky(routeKey)
@@ -3403,7 +3873,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
3403
3873
  sse(res, {
3404
3874
  type: 'notice',
3405
3875
  code: 'reasoning-leak',
3406
- tier: 'off',
3876
+ // 报**真实档位**(模型可能没有 off,叫 none/minimal):客户端据此标对那个档位按钮
3877
+ tier: firstSentEffort,
3407
3878
  text: '该模型在「关」档会把思考写进正文,已撤回泄漏段;以后这一档会自动改用「低」',
3408
3879
  })
3409
3880
  } else if (looksLikeLeakedReasoning(answer)) {
@@ -3412,7 +3883,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
3412
3883
  sse(res, {
3413
3884
  type: 'notice',
3414
3885
  code: 'reasoning-leak',
3415
- tier: 'off',
3886
+ tier: firstSentEffort,
3416
3887
  text: '这个模型在「关」档会把思考写进正文;这一档以后自动改用「低」',
3417
3888
  })
3418
3889
  }
@@ -3488,23 +3959,38 @@ export function apply(ctx: Context, rawConfig: Config): void {
3488
3959
  toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`)
3489
3960
  }
3490
3961
  }
3491
- if (!errored && answer.trim().length === 0 && toolCalls > 0 && !effortRetried) {
3962
+ if (!errored && answer.trim().length === 0 && toolCalls > 0 && triedEfforts.size === 1) {
3492
3963
  // 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
3493
3964
  // 这里自己补结论轮:**不重放工具调用链**,而是把结果写成一段自包含的提示 ——
3494
3965
  // 重放消息链的方式实测也不稳(模型面对 tool-call 历史仍可能继续吐工具调用)。
3495
3966
  conclusionInfo.tried = true
3496
3967
  sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' })
3497
3968
  const conclusionUser = [
3969
+ // ⚠️ **必须带上用户这一轮问的是什么**:这一段是最终正文的来源,而它以前只有
3970
+ // 「选区块 + 检索资料 + 请给出最终答案」—— 模型看不到问题,就只能顺着选中文字写。
3971
+ // 实测症状:问"搜索今天的AI新闻。",正文却在解释选区那个词(如 diagLog / exit)。
3972
+ // 特别是收尾轮也带上工具之后(照主会话的做法),正文**每次都**由这里产出,
3973
+ // 缺了问题就等于每次都答非所问。
3974
+ question ? `【用户这一轮的问题】(这就是你要回答的对象)\n${question}` : '',
3975
+ '',
3976
+ '【选中文字】(只是背景资料:问题需要它时才引用,不要拿它当回答的对象)',
3498
3977
  userText,
3499
3978
  '',
3500
3979
  '【已经查到的资料】',
3501
3980
  ...toolDigestLines.slice(0, 12),
3502
3981
  '',
3503
- '请**直接输出**给用户的最终答案:结论先行、必要处带来源链接。',
3982
+ '请**直接回答上面那条「用户这一轮的问题」**:结论先行、必要处带来源链接。',
3983
+ '不要复述选中文字,也不要解释它是什么 —— 除非问题问的就是它。',
3504
3984
  '不要再请求工具,不要输出任何工具调用格式,也不要写"我先看看""让我核对一下"这类过程。',
3505
3985
  ].join('\n')
3506
- // 先按当前档位试一次;空则用「关」档再试(有的模型在这一档更"直接给答案")
3507
- for (const attemptEffort of [roundEffort, 'off']) {
3986
+ // 先按当前档位试一次;空则**换一个档位**再试(同一档重发没意义)。
3987
+ // 以前这里写死 `'off'`:对"必须开推理"的端点(noOffRoutes 里的那些)等于注定失败一轮 ——
3988
+ // 用户看到的"工具查询完成了,但模型没有产出正文"就常有它一份。
3989
+ const conclusionEfforts = [
3990
+ roundEffort,
3991
+ nextEffortFor(routeKey, roundEffort, new Set(roundEffort ? [roundEffort] : [])),
3992
+ ].filter((id) => id.length > 0)
3993
+ for (const attemptEffort of conclusionEfforts) {
3508
3994
  if (answer.trim().length > 0) break
3509
3995
  conclusionInfo.rounds += 1
3510
3996
  try {
@@ -3513,6 +3999,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
3513
3999
  model: route.model,
3514
4000
  ...(sessionId ? { sessionId: sessionId as never } : {}),
3515
4001
  system: undefined,
4002
+ // 工具**依然带着**(照主会话:任何一轮都不摘工具)。
4003
+ // 以前这一轮不带工具,模型要调工具时只能退回文本层 —— 那正是正文里出现
4004
+ // DSML / ds_safety_tool_call 标记的唯一来源。带上之后它走协议层,正文就是正文。
4005
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3516
4006
  messages: [
3517
4007
  // 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
3518
4008
  systemMessageOf(systemPrompt, name),
@@ -3524,12 +4014,11 @@ export function apply(ctx: Context, rawConfig: Config): void {
3524
4014
  signal: controller.signal,
3525
4015
  })
3526
4016
  const thinkAgain = createThinkFilter()
3527
- const residueAgain = createResidueFilter()
3528
4017
  for await (const chunk of resume) {
3529
4018
  if (chunk.type === 'text-delta') {
3530
4019
  const part = thinkAgain.push(chunk.text)
3531
4020
  if (part.think) sse(res, { type: 'thought', text: part.think })
3532
- const text = part.text ? residueAgain.push(part.text) : ''
4021
+ const text = part.text
3533
4022
  if (text) {
3534
4023
  chars += text.length
3535
4024
  answer += text
@@ -3545,14 +4034,6 @@ export function apply(ctx: Context, rawConfig: Config): void {
3545
4034
  }
3546
4035
  }
3547
4036
  for (const tail of [thinkAgain.flush()]) if (tail.think) sse(res, { type: 'thought', text: tail.think })
3548
- const tailText = residueAgain.flush()
3549
- if (tailText) {
3550
- chars += tailText.length
3551
- answer += tailText
3552
- conclusionInfo.chars += tailText.length
3553
- sse(res, { type: 'delta', text: tailText })
3554
- }
3555
- residueDropped += residueAgain.dropped()
3556
4037
  // 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
3557
4038
  const leaked = splitLeakedReasoning(answer)
3558
4039
  if (leaked.prefix) {
@@ -3638,6 +4119,16 @@ export function apply(ctx: Context, rawConfig: Config): void {
3638
4119
  () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }),
3639
4120
  `${name}: pill position route`,
3640
4121
  )
4122
+ ctx.effect(
4123
+ () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/mic`, handler: handleMic }),
4124
+ `${name}: mic presence route`,
4125
+ )
4126
+ // 探针是懒启动的(第一次有人问才起),但**卸载时要收干净**:
4127
+ // 否则插件热重载几次就会留下一串没人管的 mic-probe 进程。
4128
+ ctx.effect(
4129
+ () => () => stopMicProbe(),
4130
+ `${name}: mic probe process`,
4131
+ )
3641
4132
  ctx.effect(
3642
4133
  () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }),
3643
4134
  `${name}: models route`,
@@ -3677,8 +4168,12 @@ export type { WebServer }
3677
4168
  */
3678
4169
  export const __internals = {
3679
4170
  createThinkFilter,
3680
- createResidueFilter,
3681
4171
  splitLeakedReasoning,
3682
4172
  looksLikeLeakedReasoning,
3683
4173
  resolveToolNames,
4174
+ looksLikeUnsupportedTier,
4175
+ looksLikeReasoningRequired,
4176
+ pickNextEffort,
4177
+ mapEffortToModel,
4178
+ effortMapOf,
3684
4179
  }