@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/lib/index.js CHANGED
@@ -12,9 +12,12 @@
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 { existsSync } from 'node:fs';
15
17
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
16
18
  import { homedir } from 'node:os';
17
19
  import { join, resolve, sep } from 'node:path';
20
+ import { createInterface } from 'node:readline';
18
21
  import { fileURLToPath } from 'node:url';
19
22
  import { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
20
23
  import z from '@deepseek-ai/schemastery';
@@ -48,6 +51,170 @@ export const PLUGIN_SOURCE_KIND = `plugin:${name}`;
48
51
  export const inject = ['webServer', 'llm'];
49
52
  /** 路由前缀。 */
50
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
+ };
51
218
  /** 单次请求体上限(字节)。 */
52
219
  const BODY_LIMIT = 512 * 1024;
53
220
  /** 配置 schema(缺省值即推荐值)。 */
@@ -117,6 +284,10 @@ export const Config = z.object({
117
284
  pillEnabled: z.boolean().default(true),
118
285
  /** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
119
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),
120
291
  });
121
292
  /** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
122
293
  const SEARCH_TOOL_NAMES = ['advanced_search', 'platform_search', 'web_search'];
@@ -249,6 +420,244 @@ function looksLikeLeakedReasoning(text) {
249
420
  const letters = head.replace(/[^A-Za-z]/g, '').length;
250
421
  return letters / head.length >= 0.5;
251
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
+ }
252
661
  function splitLeakedReasoning(text) {
253
662
  const body = String(text || '');
254
663
  if (body.length < 40)
@@ -283,113 +692,6 @@ function splitLeakedReasoning(text) {
283
692
  return { prefix: '', rest: body };
284
693
  return { prefix: head, rest: tail };
285
694
  }
286
- /**
287
- * 正文里不该出现的"工具调用残渣"。
288
- *
289
- * 收尾轮**不带工具**(为了逼模型给结论),模型这时会把"还想再 read 一下"当正文吐出来,
290
- * 而且用的可能是两种格式之一:
291
- * ① 官方 safety 包装:`<ds_safety_tool_call>…</ds_safety_tool_call>`(客户端以前只认这个)
292
- * ② DeepSeek 原生 DSML:全角竖线包起来的 `tool_calls / invoke / parameter` 块(实测漏过正文)
293
- *
294
- * 这里在**流里**就把它滤掉:客户端连 delta 都收不到,存盘/升格的内容也是干净的。
295
- * (客户端那份 sanitizeToolResidue 留作兜底:旧缓存、历史回放这些路径不经过 host。)
296
- */
297
- /** 全角竖线(U+FF5C)×2 + DSML + ×2:DSML 标记本身,用它拼正则避免源码里出现字面量。 */
298
- const DSML_MARK = '\uFF5C\uFF5CDSML\uFF5C\uFF5C';
299
- /**
300
- * "残渣标签"的两种形态。
301
- *
302
- * DSML 这边**不能只认 `tool_calls`**:实测模型吐出来的是**逐标签式**的,而且 marker 与标签名之间
303
- * **有一个空格** —— `<MARK parameter name="limit" string="false">40</MARK parameter>`、
304
- * `<MARK invoke name="read">…</MARK invoke>`、`<MARK calls>…</MARK calls>`。
305
- * 我第一版只匹配 `<\uFF5CDSML\uFF5Ctool_calls>`(无空格)→ 一个都没命中,残渣照漏(实测 4 轮漏 3 轮)。
306
- */
307
- const DSML_TAG = new RegExp(`<\\/?\\s*${DSML_MARK}\\s*(?:tool_calls|calls|invoke|parameter)\\b[^>]*>`, 'g');
308
- const SAFETY_TAG = /<\/?\s*ds_safety_tool_call\s*>/gi;
309
- /**
310
- * 把正文里的"工具调用残渣"整段丢掉。
311
- *
312
- * 为什么必须在流里做:收尾轮不带工具(为了逼模型给结论),模型会把"还想再调一次工具"当正文吐出来,
313
- * 客户端现有的 sanitizeToolResidue 只认官方 safety 包装,不认 DSML —— 用户看到的就是一段乱码标记。
314
- *
315
- * 算法:**按标签栈计数**。遇到开标签 depth+1、闭标签 depth-1;depth>0 期间的一切(含嵌套标签与参数值)
316
- * 全部丢弃;流在 depth>0 时结束 → 剩下的整段丢掉。这样三种实测形态(整块/缺包裹/单个标签)都能盖住。
317
- */
318
- function createResidueFilter() {
319
- const HOLD = 32; // 标签可能被切开,没命中时留一小截尾巴再输出
320
- let buffer = '';
321
- let depth = 0;
322
- let droppedChars = 0;
323
- const scan = (text) => {
324
- DSML_TAG.lastIndex = 0;
325
- const dsml = DSML_TAG.exec(text);
326
- SAFETY_TAG.lastIndex = 0;
327
- const safety = SAFETY_TAG.exec(text);
328
- if (dsml && safety)
329
- return dsml.index <= safety.index ? dsml : safety;
330
- return dsml ?? safety;
331
- };
332
- /**
333
- * 该留多长的尾巴:除了固定 32 字,还要**精确扣住"半个标签"**。
334
- * 踩过的坑:DSML 标签本身有 40+ 字(`<MARK parameter name="include" string="true">`),
335
- * 只留 32 字时标签被切在中间 → 前半截被当正文发出去,后半截又不成标签 → 残渣漏进正文(实测漏过)。
336
- * 判据:末尾最后一个 '<' 之后没有 '>',就认为它是未完成的标签,从那个 '<' 起全部扣住。
337
- */
338
- const holdOf = (text) => {
339
- const lastLt = text.lastIndexOf('<');
340
- if (lastLt >= 0 && text.indexOf('>', lastLt) < 0)
341
- return Math.max(HOLD, text.length - lastLt);
342
- return HOLD;
343
- };
344
- const drain = (final) => {
345
- let out = '';
346
- for (;;) {
347
- const hit = scan(buffer);
348
- if (!hit) {
349
- const hold = holdOf(buffer);
350
- if (depth > 0) {
351
- // 在残渣块内部:丢掉,只留足以认出闭合标签的尾巴
352
- if (final)
353
- buffer = '';
354
- else
355
- buffer = buffer.length > hold ? buffer.slice(-hold) : buffer;
356
- break;
357
- }
358
- if (final) {
359
- out += buffer;
360
- buffer = '';
361
- }
362
- else if (buffer.length > hold) {
363
- out += buffer.slice(0, buffer.length - hold);
364
- buffer = buffer.slice(-hold);
365
- }
366
- break;
367
- }
368
- if (depth === 0)
369
- out += buffer.slice(0, hit.index);
370
- const closing = hit[0].charAt(1) === '/';
371
- if (!closing)
372
- droppedChars += hit[0].length;
373
- depth = closing ? Math.max(0, depth - 1) : depth + 1;
374
- buffer = buffer.slice(hit.index + hit[0].length);
375
- }
376
- return out;
377
- };
378
- return {
379
- push: (text) => {
380
- buffer += text;
381
- return drain(false);
382
- },
383
- flush: () => {
384
- const out = drain(true);
385
- depth = 0; // 本轮结束就把状态归零:未闭合的残渣块不许影响下一轮
386
- buffer = '';
387
- return out;
388
- },
389
- /** 本轮被丢掉了多少字(用来判断"末尾残渣被删掉后句子没写完")。 */
390
- dropped: () => droppedChars,
391
- };
392
- }
393
695
  function sendJson(res, status, body) {
394
696
  const text = JSON.stringify(body);
395
697
  res.writeHead(status, {
@@ -955,6 +1257,8 @@ export function apply(ctx, rawConfig) {
955
1257
  bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
956
1258
  pillEnabled: rawConfig?.pillEnabled ?? true,
957
1259
  pillIdleMs: rawConfig?.pillIdleMs ?? 10000,
1260
+ voiceCancelOnSilence: rawConfig?.voiceCancelOnSilence ?? true,
1261
+ voiceAutoSend: rawConfig?.voiceAutoSend ?? true,
958
1262
  };
959
1263
  /**
960
1264
  * 出厂默认值(**不含**用户在设置页改过的部分)。
@@ -1065,16 +1369,26 @@ export function apply(ctx, rawConfig) {
1065
1369
  try {
1066
1370
  const raw = await readFile(leakyFile(), 'utf8');
1067
1371
  const parsed = JSON.parse(raw);
1068
- if (Array.isArray(parsed?.routes)) {
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))
1069
1385
  for (const item of parsed.routes)
1070
- if (typeof item === 'string' && item)
1071
- leakyOnOff.add(item);
1072
- }
1073
- if (Array.isArray(parsed?.noOff)) {
1386
+ accept(item, leakyOnOff);
1387
+ if (Array.isArray(parsed?.noOff))
1074
1388
  for (const item of parsed.noOff)
1075
- if (typeof item === 'string' && item)
1076
- noOffRoutes.add(item);
1077
- }
1389
+ accept(item, noOffRoutes);
1390
+ if (dropped > 0)
1391
+ saveLeaky();
1078
1392
  }
1079
1393
  catch {
1080
1394
  /* 没有这个文件是常态 */
@@ -1086,10 +1400,33 @@ export function apply(ctx, rawConfig) {
1086
1400
  ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String(error?.message ?? error)}`);
1087
1401
  });
1088
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
+ }
1089
1424
  /** 记住一个"关档会泄漏"的模型(内存 + 落盘)。 */
1090
1425
  function markLeaky(route) {
1091
1426
  if (leakyOnOff.has(route))
1092
1427
  return;
1428
+ if (!isRealProvider(route.split('/')[0] ?? ''))
1429
+ return;
1093
1430
  leakyOnOff.add(route);
1094
1431
  if (leakyLoaded)
1095
1432
  saveLeaky();
@@ -1098,10 +1435,46 @@ export function apply(ctx, rawConfig) {
1098
1435
  function markNoOff(route) {
1099
1436
  if (noOffRoutes.has(route))
1100
1437
  return;
1438
+ if (!isRealProvider(route.split('/')[0] ?? ''))
1439
+ return;
1101
1440
  noOffRoutes.add(route);
1102
1441
  if (leakyLoaded)
1103
1442
  saveLeaky();
1104
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
+ }
1105
1478
  const MODEL_CACHE_MS = 60_000;
1106
1479
  async function listModelChoices(force = false) {
1107
1480
  if (!force && modelCache.items.length > 0 && Date.now() - modelCache.at < MODEL_CACHE_MS)
@@ -1124,6 +1497,8 @@ export function apply(ctx, rawConfig) {
1124
1497
  continue;
1125
1498
  let efforts = [];
1126
1499
  let defaultEffort = null;
1500
+ // null = 没读到(调用失败 / 拿不到信息);true/false = 明确支持 / 不支持
1501
+ let hasReasoning = null;
1127
1502
  try {
1128
1503
  let info = null;
1129
1504
  if (typeof llm.resolveModelInfo === 'function') {
@@ -1132,11 +1507,15 @@ export function apply(ctx, rawConfig) {
1132
1507
  else if (typeof llm.resolveModel === 'function') {
1133
1508
  info = (await llm.resolveModel(provider.id, model.id));
1134
1509
  }
1135
- const list = info?.reasoning?.efforts ?? [];
1136
- efforts = list
1137
- .filter((item) => typeof item?.id === 'string' && item.id.length > 0)
1138
- .map((item) => ({ id: String(item.id), name: String(item.name ?? item.id) }));
1139
- defaultEffort = typeof info?.reasoning?.defaultEffort === 'string' ? info.reasoning.defaultEffort : null;
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
+ }
1140
1519
  }
1141
1520
  catch {
1142
1521
  efforts = [];
@@ -1148,6 +1527,8 @@ export function apply(ctx, rawConfig) {
1148
1527
  name: String(model.name ?? model.id),
1149
1528
  efforts,
1150
1529
  defaultEffort,
1530
+ hasReasoning,
1531
+ effortMap: effortMapOf(efforts),
1151
1532
  });
1152
1533
  }
1153
1534
  }
@@ -2753,7 +3134,14 @@ export function apply(ctx, rawConfig) {
2753
3134
  '不要为了用网页而用网页,也不要把简单问题包装成页面。\n' +
2754
3135
  '拿不准时按"能不能用一小段话讲清"来判断:能,就文字。\n' +
2755
3136
  '用网页时,页面会渲染在大约 **500px 宽**的小窗里,按下面的设计规范做:\n\n' +
2756
- skill;
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');
2757
3145
  }
2758
3146
  const fileRule = wantsFsTools
2759
3147
  ? `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
@@ -2794,6 +3182,13 @@ export function apply(ctx, rawConfig) {
2794
3182
  // 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
2795
3183
  if (requestEffort === 'off' && noOffRoutes.has(routeKey))
2796
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');
2797
3192
  sse(res, {
2798
3193
  type: 'start',
2799
3194
  provider: route.provider,
@@ -2801,7 +3196,7 @@ export function apply(ctx, rawConfig) {
2801
3196
  mode: isChat ? 'chat' : stage === 'detail' ? 'detail' : isCode ? 'code' : 'translation',
2802
3197
  stage: stage === 'detail' ? 'detail' : 'translation',
2803
3198
  backgroundMessages: background ? background.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line)).length : 0,
2804
- effort: requestEffort,
3199
+ effort: sentEffort,
2805
3200
  tools: toolSchemas.map((schema) => schema.name),
2806
3201
  toolScope: agentScope,
2807
3202
  ...(toolFallbackUsed ? { toolFallbackUsed: true } : {}),
@@ -2830,8 +3225,11 @@ export function apply(ctx, rawConfig) {
2830
3225
  });
2831
3226
  // 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
2832
3227
  const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : '';
3228
+ // 顺序很重要:把"已经查到的结果"放**前面**、问题放**最后**。
3229
+ // 反过来的话,模型最后读到的是原始检索结果,容易"整理资料"而不是回答问题
3230
+ //(实测:问新闻却把上一次的检索结果当成要总结的对象)。
2833
3231
  const questionText = toolDigest
2834
- ? `${question}\n\n【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容)\n${toolDigest}`
3232
+ ? `【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容;只在与本轮问题相关时才用)\n${toolDigest}\n\n【用户这一轮的问题】\n${question}`
2835
3233
  : question;
2836
3234
  const openingMessages = isChat
2837
3235
  ? [...historyMessages, createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: questionText }] })]
@@ -2849,7 +3247,6 @@ export function apply(ctx, rawConfig) {
2849
3247
  let errorCode;
2850
3248
  const toolDigestLines = [];
2851
3249
  /** 被残渣过滤器丢掉的字数:末尾那些"模型顺手又调了一次工具"的标记会让正文停在半句上。 */
2852
- let residueDropped = 0;
2853
3250
  /**
2854
3251
  * 逐轮诊断(放进 done 事件):原始字数 / 过滤后字数 / 思考字数 / 残渣丢弃 / finish 原因 / 输出 tokens。
2855
3252
  * 用来回答"到底是模型没吐、还是被我们的过滤器吃掉了、还是上游掐流"——不靠猜。
@@ -2868,9 +3265,18 @@ export function apply(ctx, rawConfig) {
2868
3265
  // 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
2869
3266
  // 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
2870
3267
  // 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
2871
- const canRetryEffort = requestEffort.length > 0;
2872
- let effortRetried = false;
2873
- let roundEffort = requestEffort;
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;
2874
3280
  for (let round = 0; round <= toolRounds; round += 1) {
2875
3281
  const roundStartedAt = Date.now();
2876
3282
  const wrapUp = round === toolRounds;
@@ -2893,7 +3299,10 @@ export function apply(ctx, rawConfig) {
2893
3299
  : systemPrompt, name),
2894
3300
  ...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
2895
3301
  ],
2896
- ...(!wrapUp && toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3302
+ // 工具**每轮都在请求里**(照主会话的做法):收尾轮以前故意不带工具"逼结论",
3303
+ // 结果模型退到文本层、把工具调用标记写进正文(实测 wrapUp 轮 raw=726 → emitted=0)。
3304
+ // 主会话从不摘工具,模型也就从不写这种标记 —— 这里与它保持一致。
3305
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
2897
3306
  ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
2898
3307
  ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
2899
3308
  ...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
@@ -2908,7 +3317,6 @@ export function apply(ctx, rawConfig) {
2908
3317
  // 共用一个的话,某一轮吐了未闭合的 DSML(实测收尾轮很常见)会让 depth 一直 >0,
2909
3318
  // 后面所有轮次的正文都被吞掉 —— 表现就是"只完成了工具查询,没有产出正文"(用户截图报的)。
2910
3319
  const think = createThinkFilter();
2911
- const residue = createResidueFilter();
2912
3320
  let roundRaw = 0;
2913
3321
  let roundEmitted = 0;
2914
3322
  let roundThink = 0;
@@ -2921,7 +3329,7 @@ export function apply(ctx, rawConfig) {
2921
3329
  sse(res, { type: 'thought', text: part.think });
2922
3330
  }
2923
3331
  // 残渣过滤放在 think 之后:先分出思考,再把正文里的工具调用标记丢掉
2924
- const text = part.text ? residue.push(part.text) : '';
3332
+ const text = part.text;
2925
3333
  if (text) {
2926
3334
  roundEmitted += text.length;
2927
3335
  chars += text.length;
@@ -2954,7 +3362,7 @@ export function apply(ctx, rawConfig) {
2954
3362
  roundFinish = String(chunk.reason.kind);
2955
3363
  if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
2956
3364
  errored = true;
2957
- // 先只记下来:如果接下来要"去掉档位重试",这条错误就不该发给用户
3365
+ // 先只记下来:如果接下来要"换档重试",这条错误就不该发给用户
2958
3366
  // (否则客户端先渲染一条失败提示,重试的正文就贴在失败提示后面了)
2959
3367
  errorMessage =
2960
3368
  chunk.reason.failure?.message ?? `模型调用${chunk.reason.kind === 'aborted' ? '被中断' : '失败'}`;
@@ -2963,19 +3371,6 @@ export function apply(ctx, rawConfig) {
2963
3371
  }
2964
3372
  }
2965
3373
  emit(think.flush());
2966
- // 注意:residue.flush() 吐出的正文**不能再走 emit()** —— emit 会把它再喂回同一个过滤器,
2967
- // 于是 HOLD=32 又扣住尾巴,而本轮到此结束 → 最后 32 字永远不会发出去(实测 raw−emitted 恒为 32)。
2968
- {
2969
- const tailText = residue.flush();
2970
- if (tailText) {
2971
- chars += tailText.length;
2972
- answer += tailText;
2973
- roundText += tailText;
2974
- roundEmitted += tailText.length;
2975
- sse(res, { type: 'delta', text: tailText });
2976
- }
2977
- }
2978
- residueDropped += residue.dropped();
2979
3374
  roundDiag.push({
2980
3375
  round,
2981
3376
  wrapUp,
@@ -2983,39 +3378,49 @@ export function apply(ctx, rawConfig) {
2983
3378
  raw: roundRaw,
2984
3379
  emitted: roundEmitted,
2985
3380
  think: roundThink,
2986
- residueDropped: residue.dropped(),
2987
3381
  finish: roundFinish || '(none)',
2988
3382
  tools: assembler.blocks().filter((block) => block.type === 'tool-call').length,
2989
3383
  ...(body.debug === true ? { tailChunks } : {}),
2990
3384
  outTokens: usage?.outputTokens,
2991
3385
  });
2992
3386
  if (errored) {
2993
- // 一个字都没吐出来 + 是显式档位导致的失败 → 去掉档位重试**同一轮**
2994
- // (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
2995
- if (canRetryEffort && !effortRetried && roundText.length === 0) {
2996
- effortRetried = true;
3387
+ // 一个字都没吐出来 → 可以安全重试**同一轮**(保守:已经吐出正文的失败不重试,
3388
+ // 免得把半截答案丢掉)。重试换一个没试过的档位,而不是把档位去掉。
3389
+ if (roundText.length === 0 && effortRetries > 0) {
2997
3390
  const rejected = roundEffort;
2998
- if (rejected === 'off') {
2999
- // "关"被拒:记下这个 route,改用 low(最接近关的可接受档位),并让重试也用 low。
3000
- // 只去掉档位(交给供应商默认)在有些模型上等于开了深度思考 —— 又慢又可能把思考写进正文。
3001
- markNoOff(routeKey);
3002
- requestEffort = 'low';
3003
- roundEffort = 'low';
3004
- }
3005
- else {
3006
- 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;
3007
3423
  }
3008
- errored = false;
3009
- sse(res, {
3010
- type: 'notice',
3011
- code: 'effort-rejected',
3012
- tier: rejected,
3013
- text: rejected === 'off'
3014
- ? '该模型不支持「关」档,已自动改用「低」档(以后不再尝试关档)'
3015
- : `「${rejected}」档位该模型不支持,已按默认档位重试`,
3016
- });
3017
- round -= 1;
3018
- continue;
3019
3424
  }
3020
3425
  sse(res, { type: 'error', message: errorMessage, code: errorCode });
3021
3426
  break;
@@ -3023,8 +3428,8 @@ export function apply(ctx, rawConfig) {
3023
3428
  const blocks = assembler.blocks();
3024
3429
  const calls = blocks.filter((block) => block.type === 'tool-call');
3025
3430
  if (calls.length === 0) {
3026
- // 最终答案到手:如果用的是 "关" 档,检查有没有把思考写进正文
3027
- if (isChat && requestEffort === 'off') {
3431
+ // 最终答案到手:如果发出去的是「关」那一格对应的真实档位,检查有没有把思考写进正文
3432
+ if (isChat && firstSentEffort === offSlotEffort) {
3028
3433
  const split = splitLeakedReasoning(answer);
3029
3434
  if (split.prefix) {
3030
3435
  markLeaky(routeKey);
@@ -3034,7 +3439,8 @@ export function apply(ctx, rawConfig) {
3034
3439
  sse(res, {
3035
3440
  type: 'notice',
3036
3441
  code: 'reasoning-leak',
3037
- tier: 'off',
3442
+ // 报**真实档位**(模型可能没有 off,叫 none/minimal):客户端据此标对那个档位按钮
3443
+ tier: firstSentEffort,
3038
3444
  text: '该模型在「关」档会把思考写进正文,已撤回泄漏段;以后这一档会自动改用「低」',
3039
3445
  });
3040
3446
  }
@@ -3044,7 +3450,7 @@ export function apply(ctx, rawConfig) {
3044
3450
  sse(res, {
3045
3451
  type: 'notice',
3046
3452
  code: 'reasoning-leak',
3047
- tier: 'off',
3453
+ tier: firstSentEffort,
3048
3454
  text: '这个模型在「关」档会把思考写进正文;这一档以后自动改用「低」',
3049
3455
  });
3050
3456
  }
@@ -3120,23 +3526,38 @@ export function apply(ctx, rawConfig) {
3120
3526
  toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`);
3121
3527
  }
3122
3528
  }
3123
- if (!errored && answer.trim().length === 0 && toolCalls > 0 && !effortRetried) {
3529
+ if (!errored && answer.trim().length === 0 && toolCalls > 0 && triedEfforts.size === 1) {
3124
3530
  // 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
3125
3531
  // 这里自己补结论轮:**不重放工具调用链**,而是把结果写成一段自包含的提示 ——
3126
3532
  // 重放消息链的方式实测也不稳(模型面对 tool-call 历史仍可能继续吐工具调用)。
3127
3533
  conclusionInfo.tried = true;
3128
3534
  sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' });
3129
3535
  const conclusionUser = [
3536
+ // ⚠️ **必须带上用户这一轮问的是什么**:这一段是最终正文的来源,而它以前只有
3537
+ // 「选区块 + 检索资料 + 请给出最终答案」—— 模型看不到问题,就只能顺着选中文字写。
3538
+ // 实测症状:问"搜索今天的AI新闻。",正文却在解释选区那个词(如 diagLog / exit)。
3539
+ // 特别是收尾轮也带上工具之后(照主会话的做法),正文**每次都**由这里产出,
3540
+ // 缺了问题就等于每次都答非所问。
3541
+ question ? `【用户这一轮的问题】(这就是你要回答的对象)\n${question}` : '',
3542
+ '',
3543
+ '【选中文字】(只是背景资料:问题需要它时才引用,不要拿它当回答的对象)',
3130
3544
  userText,
3131
3545
  '',
3132
3546
  '【已经查到的资料】',
3133
3547
  ...toolDigestLines.slice(0, 12),
3134
3548
  '',
3135
- '请**直接输出**给用户的最终答案:结论先行、必要处带来源链接。',
3549
+ '请**直接回答上面那条「用户这一轮的问题」**:结论先行、必要处带来源链接。',
3550
+ '不要复述选中文字,也不要解释它是什么 —— 除非问题问的就是它。',
3136
3551
  '不要再请求工具,不要输出任何工具调用格式,也不要写"我先看看""让我核对一下"这类过程。',
3137
3552
  ].join('\n');
3138
- // 先按当前档位试一次;空则用「关」档再试(有的模型在这一档更"直接给答案")
3139
- for (const attemptEffort of [roundEffort, 'off']) {
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) {
3140
3561
  if (answer.trim().length > 0)
3141
3562
  break;
3142
3563
  conclusionInfo.rounds += 1;
@@ -3146,6 +3567,10 @@ export function apply(ctx, rawConfig) {
3146
3567
  model: route.model,
3147
3568
  ...(sessionId ? { sessionId: sessionId } : {}),
3148
3569
  system: undefined,
3570
+ // 工具**依然带着**(照主会话:任何一轮都不摘工具)。
3571
+ // 以前这一轮不带工具,模型要调工具时只能退回文本层 —— 那正是正文里出现
3572
+ // DSML / ds_safety_tool_call 标记的唯一来源。带上之后它走协议层,正文就是正文。
3573
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
3149
3574
  messages: [
3150
3575
  // 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
3151
3576
  systemMessageOf(systemPrompt, name),
@@ -3157,13 +3582,12 @@ export function apply(ctx, rawConfig) {
3157
3582
  signal: controller.signal,
3158
3583
  });
3159
3584
  const thinkAgain = createThinkFilter();
3160
- const residueAgain = createResidueFilter();
3161
3585
  for await (const chunk of resume) {
3162
3586
  if (chunk.type === 'text-delta') {
3163
3587
  const part = thinkAgain.push(chunk.text);
3164
3588
  if (part.think)
3165
3589
  sse(res, { type: 'thought', text: part.think });
3166
- const text = part.text ? residueAgain.push(part.text) : '';
3590
+ const text = part.text;
3167
3591
  if (text) {
3168
3592
  chars += text.length;
3169
3593
  answer += text;
@@ -3183,14 +3607,6 @@ export function apply(ctx, rawConfig) {
3183
3607
  for (const tail of [thinkAgain.flush()])
3184
3608
  if (tail.think)
3185
3609
  sse(res, { type: 'thought', text: tail.think });
3186
- const tailText = residueAgain.flush();
3187
- if (tailText) {
3188
- chars += tailText.length;
3189
- answer += tailText;
3190
- conclusionInfo.chars += tailText.length;
3191
- sse(res, { type: 'delta', text: tailText });
3192
- }
3193
- residueDropped += residueAgain.dropped();
3194
3610
  // 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
3195
3611
  const leaked = splitLeakedReasoning(answer);
3196
3612
  if (leaked.prefix) {
@@ -3257,6 +3673,10 @@ export function apply(ctx, rawConfig) {
3257
3673
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }), `${name}: history route`);
3258
3674
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/panel`, handler: handlePanelSize }), `${name}: panel box route`);
3259
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`);
3260
3680
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }), `${name}: models route`);
3261
3681
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }), `${name}: promote route`);
3262
3682
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/quote-context`, handler: handleQuoteContext }), `${name}: quote-context route`);
@@ -3271,9 +3691,13 @@ export function apply(ctx, rawConfig) {
3271
3691
  */
3272
3692
  export const __internals = {
3273
3693
  createThinkFilter,
3274
- createResidueFilter,
3275
3694
  splitLeakedReasoning,
3276
3695
  looksLikeLeakedReasoning,
3277
3696
  resolveToolNames,
3697
+ looksLikeUnsupportedTier,
3698
+ looksLikeReasoningRequired,
3699
+ pickNextEffort,
3700
+ mapEffortToModel,
3701
+ effortMapOf,
3278
3702
  };
3279
3703
  //# sourceMappingURL=index.js.map