@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
@@ -1,129 +0,0 @@
1
- /**
2
- * 四格 → 模型真实档位:映射规则的确定性单测(不碰网络、不依赖宿主)。
3
- *
4
- * 为什么要有这一份:设置页那四个格子(关/低/高/最高)是**插件自己的词汇**,而每个模型
5
- * 声明的档位词汇表各不相同 —— 本机 74 个模型上就数出七种(off>low>high>max、
6
- * low>high>max、high>max、off>minimal>low>medium>high、off>low>medium>high>xhigh>max、
7
- * low>medium>high>xhigh、minimal>low>medium>high>xhigh、只有 max、以及一档都不声明)。
8
- * 映射是**运行时按模型声明现算**的(没有任何按模型写死的表),所以这份用例既锁住
9
- * "见过的那几种",也用**随机生成、插件作者没见过的词汇表**验两条不变量:
10
- * ① 落点永远是这个模型声明过的档位(否则 dsh-llm 直接抛 UNSUPPORTED_REASONING_EFFORT);
11
- * ② 关 ≤ 低 ≤ 高 ≤ 最高(按声明的先后顺序,声明都是升序)。
12
- */
13
- import { __internals } from '../lib/index.js'
14
-
15
- const { mapEffortToModel, effortMapOf } = __internals
16
-
17
- let failed = 0
18
- function assert(name, ok, detail) {
19
- if (ok) {
20
- console.log(' ✓ ' + name)
21
- } else {
22
- failed += 1
23
- console.log(' ✗ ' + name + (detail ? ' — ' + detail : ''))
24
- }
25
- }
26
-
27
- /** 把一个模型声明的 id 列表转成映射函数用的形状(name 就取 id)。 */
28
- const decl = (ids) => ids.map((id) => ({ id, name: id }))
29
- /** 四格一次算完,返回 ['关','低','高','最高'] 的落点。 */
30
- const four = (ids) => {
31
- const map = effortMapOf(decl(ids))
32
- return [map.off, map.low, map.high, map.max]
33
- }
34
- const show = (ids) => {
35
- const r = four(ids)
36
- return `${ids.join('>')} → 关=${r[0]} 低=${r[1]} 高=${r[2]} 最高=${r[3]}`
37
- }
38
-
39
- console.log('[1] 本机真实词汇表(2026-10-10 从 /models 抓下来的形状)')
40
- const REAL = [
41
- // 最常见:四档,名字与四个格子一一对应 → 恒等映射
42
- { ids: ['off', 'low', 'high', 'max'], want: ['off', 'low', 'high', 'max'] },
43
- // 六档,中间多出 medium / xhigh:仍然落在名为 low / high 的那两档上
44
- { ids: ['off', 'low', 'medium', 'high', 'xhigh', 'max'], want: ['off', 'low', 'high', 'max'] },
45
- // 没有 off(deepseek-v4.1-flash 这种):关落到它最弱的那档
46
- { ids: ['low', 'high', 'max'], want: ['low', 'high', 'high', 'max'] },
47
- // 只有两档(deepseek-v4-pro / glm-5.2):最弱留给关,其余都只能落最强
48
- { ids: ['high', 'max'], want: ['high', 'max', 'max', 'max'] },
49
- // 五档且没有 max(glm-5.1 / mimo-*):最高落到它最强的那档
50
- { ids: ['off', 'minimal', 'low', 'medium', 'high'], want: ['off', 'low', 'medium', 'high'] },
51
- // 四档但顶档叫 xhigh(qwen3.8-max/flash)
52
- { ids: ['off', 'low', 'medium', 'xhigh'], want: ['off', 'low', 'medium', 'xhigh'] },
53
- // 没有 off 但有 xhigh(grok-4.6/4.7)
54
- { ids: ['low', 'medium', 'high', 'xhigh'], want: ['low', 'medium', 'high', 'xhigh'] },
55
- // 最少见的一档(kimi-k3 只有 max):四格都只能落它
56
- { ids: ['max'], want: ['max', 'max', 'max', 'max'] },
57
- // 完全不支持推理(typesafe/jev-latest):四个格子都空 → 调用方据此不发档位
58
- { ids: [], want: ['', '', '', ''] },
59
- // 两档(hy4-preview / kimi-k2.6):关=off,其余落它唯一的高档
60
- { ids: ['off', 'high'], want: ['off', 'high', 'high', 'high'] },
61
- ]
62
- for (const item of REAL) {
63
- const got = four(item.ids)
64
- assert(show(item.ids), JSON.stringify(got) === JSON.stringify(item.want), '期望 ' + JSON.stringify(item.want))
65
- }
66
-
67
- console.log('\n[2] 没见过的词汇表也要落位(别的 adapter 可能叫 a/b/c、tier1…、或数字)')
68
- const ALIEN = [
69
- { ids: ['a', 'b', 'c', 'd'], want: ['a', 'b', 'c', 'd'] },
70
- { ids: ['tier1', 'tier2', 'tier3', 'tier4', 'tier5'], want: ['tier1', 'tier2', 'tier4', 'tier5'] },
71
- { ids: ['1', '2', '3'], want: ['1', '2', '2', '3'] },
72
- { ids: ['x', 'y'], want: ['x', 'y', 'y', 'y'] },
73
- // 半认半不认:'low' 认得出深度,其余按位置插值
74
- { ids: ['stage0', 'low', 'stage2', 'stage3'], want: ['stage0', 'low', 'stage2', 'stage3'] },
75
- ]
76
- for (const item of ALIEN) {
77
- const got = four(item.ids)
78
- assert(show(item.ids), JSON.stringify(got) === JSON.stringify(item.want), '期望 ' + JSON.stringify(item.want))
79
- }
80
-
81
- console.log('\n[3] 两条不变量:落点必须已声明;关 ≤ 低 ≤ 高 ≤ 最高(按声明顺序)')
82
- /** 造一批"升序"的词汇表:长度 1..7,名字在几套风格之间轮换(含插件作者没见过的)。 */
83
- const NAMERS = [
84
- (i) => ['off', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'][i] ?? `known${i}`,
85
- (i) => ['none', 'minimal', 'light', 'mid', 'high', 'max', 'highest'][i] ?? `style${i}`,
86
- (i) => `tier${i + 1}`,
87
- (i) => String.fromCharCode(97 + i), // a, b, c…
88
- (i) => `档位${i + 1}`,
89
- (i) => `stage${i * 2}`,
90
- ]
91
- let cases = 0
92
- let bad = 0
93
- for (let n = 1; n <= 7; n += 1) {
94
- for (let style = 0; style < NAMERS.length; style += 1) {
95
- const ids = []
96
- for (let i = 0; i < n; i += 1) ids.push(NAMERS[style](i))
97
- const got = four(ids)
98
- cases += 1
99
- // ① 每个落点都必须是这个模型声明过的 id
100
- for (const value of got) {
101
- if (!ids.includes(value)) {
102
- bad += 1
103
- console.log(' ✗ 未声明的落点:' + show(ids) + ' 里出现了 ' + JSON.stringify(value))
104
- break
105
- }
106
- }
107
- // ② 单调:按下标比较(声明都是升序)
108
- const order = got.map((value) => ids.indexOf(value))
109
- const monotone = order.every((value, index) => index === 0 || order[index - 1] <= value)
110
- if (!monotone) {
111
- bad += 1
112
- console.log(' ✗ 非单调:' + show(ids) + ' 下标=' + JSON.stringify(order))
113
- }
114
- // ③ 两端钉死:关 = 最弱、最高 = 最强(列表升序 = 下标顺序)
115
- if (got[0] !== ids[0] || got[3] !== ids[n - 1]) {
116
- bad += 1
117
- console.log(' ✗ 两端没钉住:' + show(ids))
118
- }
119
- }
120
- }
121
- assert(`${cases} 组合成词汇表全部满足不变量`, bad === 0, `${bad} 组有问题`)
122
-
123
- console.log('\n[4] 显式值、大小写与连字符都认(adapter 的显示名可能写成 "X-High" / "Very High")')
124
- assert("显示名 'X-High' 认成最高档之下的那一档", mapEffortToModel([{ id: 'a', name: 'Low' }, { id: 'b', name: 'X-High' }], 'max') === 'b')
125
- assert("id 大写 'OFF' 也算关", mapEffortToModel([{ id: 'OFF', name: 'Off' }, { id: 'HIGH', name: 'High' }], 'off') === 'OFF')
126
- assert('没见过的规范值按"关"处理(不会越界)', mapEffortToModel(decl(['off', 'low', 'high']), 'something-else') === 'off')
127
-
128
- console.log(failed === 0 ? '\n全部通过 ✓' : `\n${failed} 项失败 ✗`)
129
- process.exit(failed === 0 ? 0 : 1)
@@ -1,109 +0,0 @@
1
- /**
2
- * 档位重试策略的确定性单测(不碰网络、不依赖宿主)。
3
- *
4
- * 为什么必须有这一份:这套逻辑的输入是**上游错误的文案**,而它曾经把
5
- * 「偶发上游 400」误判成「这个档位不被支持」,连锁出三个错:
6
- * ① 给用户一句错诊断(「该档位不支持」);
7
- * ② 把这次偶发失败**永久记进** leaky-off.json(以后再也不发这个档);
8
- * ③ 客户端清掉用户选的档位。
9
- * 2026-10-09 实测(活宿主 /analyze,交错发送排除时间漂移):
10
- * opencode-go/step-5-preview-free:effort=low 成功 5/8、effort=high 成功 5/8
11
- * —— **同一档位时好时坏,档位不是变量**;对照 longcat-2.5-preview-free 6/6 成功。
12
- * 所以下面第一条断言就是那个回归闸门。
13
- *
14
- * 另一条被锁住的事实:老代码重试时把档位**整个去掉**(省略 reasoning),
15
- * 实测 20 次重试 0 次成功 —— 对"必须开推理"的端点,省略等于把推理关掉。
16
- * 新策略永远给一个显式档位(pickNextEffort 不会返回空,除非真的没档可换)。
17
- */
18
- import { __internals } from '../lib/index.js'
19
-
20
- const { looksLikeUnsupportedTier, looksLikeReasoningRequired, pickNextEffort } = __internals
21
-
22
- let failed = 0
23
- function assert(name, ok, detail) {
24
- if (ok) {
25
- console.log(' ✓ ' + name)
26
- } else {
27
- failed += 1
28
- console.log(' ✗ ' + name + (detail ? ' — ' + detail : ''))
29
- }
30
- }
31
-
32
- // ── ① 那条真实的上游文案 ──
33
- const STEP5_FLAKY =
34
- '400: {"type":"server_error","message":"Upstream request failed: [400] Reasoning is mandatory for this endpoint and cannot be disabled."}'
35
-
36
- console.log('[1] 偶发上游错误不能被当成"档位不支持"')
37
- assert('step-5 那句原文 → 不是"档位无效"', looksLikeUnsupportedTier(STEP5_FLAKY) === false)
38
- assert('step-5 那句原文 → 是"必须开推理"(只在发关档时才算数)', looksLikeReasoningRequired(STEP5_FLAKY) === true)
39
- assert(
40
- '同一句在发 high 时不该被当成档位问题(我们没关推理)',
41
- // 调用方的判据:rejected !== 'off' 时用 looksLikeUnsupportedTier
42
- looksLikeUnsupportedTier(STEP5_FLAKY) === false,
43
- )
44
-
45
- console.log('\n[2] 真正"档位不被支持"的措辞要认得出来')
46
- for (const text of [
47
- 'provider "opencode-go" model "x" does not support reasoning effort "max"',
48
- 'invalid reasoning effort: max',
49
- 'unsupported reasoning_effort value',
50
- '该模型不支持 reasoning 档位',
51
- 'thinking 参数无效',
52
- 'Unrecognized effort "medium"',
53
- ]) {
54
- assert('认得:' + text.slice(0, 42), looksLikeUnsupportedTier(text) === true)
55
- }
56
-
57
- console.log('\n[3] 无关错误不要误判成档位问题')
58
- for (const text of [
59
- 'Upstream request failed: [500] internal error',
60
- 'network timeout',
61
- '401 unauthorized: invalid api key',
62
- 'Rate limit exceeded',
63
- '模型调用失败',
64
- ]) {
65
- assert('不误判:' + text.slice(0, 34), looksLikeUnsupportedTier(text) === false)
66
- }
67
-
68
- console.log('\n[4] 换档:永远给显式档位,且不重复发同一个档')
69
- const DECLARED = ['off', 'low', 'high', 'max']
70
- assert('off 被拒 → 低(最接近关的可接受档)', pickNextEffort(DECLARED, null, 'off', new Set(['off'])) === 'low')
71
- assert('low 被拒 → 高', pickNextEffort(DECLARED, null, 'low', new Set(['low'])) === 'high')
72
- assert('high 被拒 → 最大', pickNextEffort(DECLARED, null, 'high', new Set(['high'])) === 'max')
73
- assert(
74
- 'max 被拒 → 退到高(**不是**退回关:off 恰恰最容易被拒)',
75
- pickNextEffort(DECLARED, null, 'max', new Set(['max'])) === 'high',
76
- )
77
- assert(
78
- '模型声明的 defaultEffort 优先',
79
- pickNextEffort(DECLARED, 'medium', 'high', new Set(['high'])) === 'medium',
80
- )
81
- assert(
82
- 'defaultEffort 就是被拒的那个 → 继续沿阶梯找',
83
- pickNextEffort(DECLARED, 'high', 'high', new Set(['high'])) === 'max',
84
- )
85
- assert(
86
- '没档位可换时返回空串(调用方据此不重试,而不是省略档位)',
87
- pickNextEffort(['high'], null, 'high', new Set(['high'])) === '',
88
- )
89
- assert(
90
- '不会发模型没声明的档(只声明 off/low 时不去发 high)',
91
- pickNextEffort(['off', 'low'], null, 'low', new Set(['low'])) === 'off',
92
- )
93
- assert(
94
- '连着重试两次:两次都不重复(off → low → high)',
95
- (() => {
96
- const tried = new Set(['off'])
97
- const a = pickNextEffort(DECLARED, null, 'off', tried)
98
- tried.add(a)
99
- const b = pickNextEffort(DECLARED, null, a, tried)
100
- return a === 'low' && b === 'high'
101
- })(),
102
- )
103
- assert(
104
- '清单还没加载(declared 空)时也能换:走完整阶梯',
105
- pickNextEffort([], null, 'low', new Set(['low'])) === 'high',
106
- )
107
-
108
- console.log(failed === 0 ? '\n全部通过 ✓' : `\n${failed} 项失败 ✗`)
109
- process.exit(failed === 0 ? 0 : 1)
@@ -1,101 +0,0 @@
1
- /** Run the actual document Esc handler with isolated focus and panel state. No host/API access. */
2
- import assert from 'node:assert/strict'
3
- import { readFileSync } from 'node:fs'
4
- import vm from 'node:vm'
5
- const source = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
6
- const start = source.indexOf(' var offKeyDown = listen(document,')
7
- const end = source.indexOf('\n\n var offSettingsOpen', start)
8
- assert.ok(start >= 0 && end > start)
9
- const ask = {}, historyInput = {}, outside = {}, expandedChat = { closest: selector => selector.startsWith('[data-dsh-floating-chat]') ? {} : null }, compactChat = { closest: () => null }, nativeMenu = { closest: selector => selector.startsWith('[role=') ? {} : null }
10
- let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true, hostModalOpen = false
11
- const context = vm.createContext({
12
- panelOpen: true, settingsOpen: false,
13
- panel: { contains: node => node === ask },
14
- historyList: { contains: node => node === historyInput },
15
- document: { querySelector: selector => selector === '[role="dialog"][aria-modal="true"]'
16
- ? hostModalOpen ? {} : null
17
- : selector === '[data-dsh-full-view]' && fullView
18
- ? { getAttribute: () => activeWindow }
19
- : selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
20
- listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
21
- captureRow: { getAttribute: () => '0' }, settingsByKey: {},
22
- closeModelPicks() { calls.push('menu') },
23
- closeSettings() { calls.push('settings'); context.settingsOpen = false },
24
- cancelVoice() { calls.push('voice') },
25
- closePanel() { calls.push('panel'); context.panelOpen = false },
26
- })
27
- new vm.Script(source.slice(start, end)).runInContext(context)
28
- function escape(target, extra = {}) {
29
- const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
30
- handler(event)
31
- }
32
- function reset() { calls = []; activeWindow = null; chatExpanded = true; hostModalOpen = false; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
33
- for (const mode of [true, false]) {
34
- for (const target of [ask, historyInput, outside, expandedChat]) {
35
- reset(); fullView = mode; hostModalOpen = true; activeWindow = 'explanation'
36
- escape(target)
37
- assert.deepEqual(calls, [], '官方模态设置浮层优先消费 Esc,不受视图模式、焦点或最后操作窗口影响')
38
- assert.equal(context.panelOpen, true)
39
- }
40
- }
41
- reset(); fullView = true; hostModalOpen = true; context.settingsOpen = true; escape(ask)
42
- assert.deepEqual(calls, [], '官方设置打开时不关闭插件设置抽屉')
43
- assert.equal(context.settingsOpen, true)
44
- reset(); hostModalOpen = true; context.captureRow.getAttribute = () => '1'; escape(ask)
45
- assert.deepEqual(calls, [], '官方设置打开时不取消插件录音')
46
- hostModalOpen = false; escape(ask)
47
- assert.deepEqual(calls, ['prevent', 'stop', 'voice'], '官方设置退出后,下一次 Esc 恢复插件的内层退出顺序')
48
- reset(); activeWindow = 'chat'; escape(ask)
49
- assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
50
- reset(); activeWindow = 'explanation'; escape(expandedChat)
51
- assert.equal(context.panelOpen, false, '最后点击解读消息后,即使键盘仍在聊天,也先退解读')
52
- reset(); activeWindow = 'none'; escape(expandedChat)
53
- assert.equal(context.panelOpen, false, '点击两窗外后不使用旧键盘焦点,优先退解读')
54
- reset(); activeWindow = 'chat'; chatExpanded = false; escape(compactChat)
55
- assert.equal(context.panelOpen, false, '已收起的聊天不占下一次退出顺序')
56
- reset()
57
- escape(outside)
58
- assert.equal(context.panelOpen, false, '焦点在两窗之外时优先关闭解读')
59
- reset(); escape(expandedChat)
60
- assert.deepEqual(calls, [], '展开聊天的 Esc 交给聊天,不关闭解读')
61
- assert.equal(context.panelOpen, true)
62
- reset(); escape(compactChat)
63
- assert.equal(context.panelOpen, false, '聊天收起后焦点留在紧凑条,再按 Esc 仍能关闭解读')
64
- reset(); escape(nativeMenu)
65
- assert.deepEqual(calls, [], '原生菜单先处理自身 Esc,不连带关闭解读')
66
- for (const extra of [{ isComposing: true }, { keyCode: 229 }, { defaultPrevented: true }]) {
67
- reset(); escape(ask, extra); assert.deepEqual(calls, [], '输入法或已处理的 Esc 不关闭解读')
68
- }
69
- reset(); context.settingsOpen = true; context.settingsByKey = { model: { menu: { getAttribute: () => '1' } } }; escape(ask)
70
- assert.deepEqual(calls, ['prevent', 'stop', 'menu'])
71
- assert.equal(context.panelOpen, true)
72
- reset(); context.settingsOpen = true; escape(ask)
73
- assert.deepEqual(calls, ['prevent', 'stop', 'settings'])
74
- assert.equal(context.panelOpen, true)
75
- reset(); context.captureRow.getAttribute = () => '1'; escape(ask)
76
- assert.deepEqual(calls, ['prevent', 'stop', 'voice'])
77
- assert.equal(context.panelOpen, true)
78
- for (const target of [ask, historyInput]) {
79
- reset(); escape(target); assert.deepEqual(calls, ['prevent', 'stop', 'panel']); assert.equal(context.panelOpen, false)
80
- }
81
- reset(); fullView = false; escape(outside)
82
- assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
83
-
84
- // 语音卡另有一个更早注册的 capture 监听,也必须给官方浮层让出 Esc。
85
- const panelHandler = handler
86
- const voiceStart = source.indexOf(' var offVoiceEscape = listen(document,')
87
- const voiceEnd = source.indexOf('\n }, true)', voiceStart) + '\n }, true)'.length
88
- assert.ok(voiceStart >= 0 && voiceEnd > voiceStart)
89
- context.vcardState = { open: true, suppress: false }
90
- context.closeVCard = () => { calls.push('vcard'); context.vcardState.open = false }
91
- new vm.Script(source.slice(voiceStart, voiceEnd)).runInContext(context)
92
- const voiceHandler = handler
93
- handler = panelHandler
94
- reset(); hostModalOpen = true
95
- voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
96
- assert.deepEqual(calls, [], '官方设置打开时,语音卡不抢先消费 Esc')
97
- assert.equal(context.vcardState.open, true)
98
- hostModalOpen = false
99
- voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
100
- assert.deepEqual(calls, ['prevent', 'stop', 'vcard'], '官方设置退出后,Esc 可以关闭语音卡')
101
- console.log('PASS focused Esc: host modals, independent windows, IME, nested settings, voice, history and split-mode behavior')
@@ -1,146 +0,0 @@
1
- /**
2
- * 流式文本手术刀的确定性单测:思考过滤器 / 泄漏思考判别 / 工具清单解析。
3
- *
4
- * 为什么单独一个文件:这三件事都只能靠"模型吐什么"来发现(思考内联、DSML 残渣都是实测撞出来的),
5
- * 而模型每次发挥不同 —— 靠真实请求验证会时灵时不灵。这里把 lib/index.js 里的纯函数直接拿来打表。
6
- *
7
- * 注:原先这里还有一节"工具调用残渣过滤器" —— 那个过滤器已经**整体删除**(不再做文本层清洗,
8
- * 改为照主会话的做法:工具每轮都带在请求里,模型走协议层表达调用,正文里不会有这种标记)。
9
- *
10
- * 跑法:node scripts/test-filters.mjs(已挂进 npm test)
11
- */
12
- import { createRequire } from 'node:module'
13
- import { fileURLToPath } from 'node:url'
14
- import { dirname, resolve } from 'node:path'
15
-
16
- const HERE = dirname(fileURLToPath(import.meta.url))
17
- const require = createRequire(import.meta.url)
18
- const { __internals } = require(resolve(HERE, '..', 'lib', 'index.js'))
19
- const { createThinkFilter, splitLeakedReasoning, looksLikeLeakedReasoning, resolveToolNames } = __internals
20
-
21
- let pass = 0
22
- let fail = 0
23
- const assert = (name, ok, detail) => {
24
- if (ok) {
25
- pass += 1
26
- console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`)
27
- } else {
28
- fail += 1
29
- console.log(`FAIL ${name}${detail ? ` — ${detail}` : ''}`)
30
- }
31
- }
32
-
33
- /** 全角竖线包起来的 DSML 标记(实测就是这个字符 U+FF5C)。 */
34
- const VB = String.fromCharCode(0xff5c)
35
- const M = VB + VB + 'DSML' + VB + VB
36
-
37
- // ───────────────────────── 思考过滤器 ─────────────────────────
38
- {
39
- const f = createThinkFilter()
40
- const a = f.push('<think>先想想</think>答案在这里')
41
- const b = f.flush()
42
- assert('think:标签内的走思考、标签外留正文', a.think + b.think === '先想想' && a.text + b.text === '答案在这里', JSON.stringify({ a, b }))
43
- }
44
- {
45
- // 标签被切成两块(流式最常见的形态)
46
- const f = createThinkFilter()
47
- const parts = ['<thi', 'nk>The user is', ' asking</thi', 'nk>我是 Omen Alpha。']
48
- let text = ''
49
- let think = ''
50
- for (const p of parts) {
51
- const r = f.push(p)
52
- text += r.text
53
- think += r.think
54
- }
55
- const t = f.flush()
56
- text += t.text
57
- think += t.think
58
- assert('think:标签被切开也能正确分派', think === 'The user is asking' && text === '我是 Omen Alpha。', JSON.stringify({ text, think }))
59
- }
60
- {
61
- // 流断在半截(未闭合):整段都算思考,绝不落进正文
62
- const f = createThinkFilter()
63
- const a = f.push('<thinking>想到一半就断了')
64
- const b = f.flush()
65
- assert('think:未闭合的思考块不落进正文', a.text + b.text === '' && (a.think + b.think).indexOf('想到一半') >= 0, JSON.stringify({ a, b }))
66
- }
67
- {
68
- const f = createThinkFilter()
69
- const a = f.push('普通正文,没有思考标签')
70
- const b = f.flush()
71
- assert('think:没有标签时原样输出(不误伤)', a.text + b.text === '普通正文,没有思考标签' && a.think + b.think === '', '')
72
- }
73
-
74
- // ───────────────────────── 泄漏思考判别 ─────────────────────────
75
- {
76
- // 三次实测撞到的真实样本
77
- const samples = [
78
- 'The user is asking which model I am. According to my instructions, I should always say I am Omen Alpha.我是 **Omen Alpha**。',
79
- 'Per my instructions, I always answer that I am Omen Alpha.我是 **Omen Alpha**。',
80
- 'The user is asking again what model I am. Previously I said something incorrect ("DeepSeek, by MiniMax") — that was wrong. According to my system prompt: "always say Omen Alpha." So I should answer Omen Alpha.我是 **Omen Alpha**。',
81
- ]
82
- let ok = true
83
- let detail = ''
84
- for (const sample of samples) {
85
- const split = splitLeakedReasoning(sample)
86
- if (!split.prefix) {
87
- ok = false
88
- detail = `没认出: ${sample.slice(0, 40)}`
89
- break
90
- }
91
- if (split.prefix + split.rest !== sample) {
92
- ok = false
93
- detail = '拼接不还原(撤回段与正文对不上,客户端会撤不掉)'
94
- break
95
- }
96
- if (/The user|According to|Per my|I should/i.test(split.rest)) {
97
- ok = false
98
- detail = `裁完还剩英文盘算: ${split.rest.slice(0, 40)}`
99
- break
100
- }
101
- }
102
- assert('泄漏:三个真实样本都能裁干净且拼接还原', ok, detail)
103
- }
104
- {
105
- // 中文正文 + 英文专有名词:不能误裁
106
- const text = '`delta` 是流式响应里每次推送的增量片段。DeepSeek Harness 把它拼起来才是完整回答。'
107
- const split = splitLeakedReasoning(text)
108
- assert('泄漏:中文正文不误裁', split.prefix === '' && split.rest === text, JSON.stringify(split.prefix.slice(0, 30)))
109
- }
110
- {
111
- // 宽松判据(只用于"记住这个模型"):换说法也能识别
112
- const vague = 'Hmm, the user wants to know my identity. Let me think about what to say here. 我是 Omen Alpha。'
113
- assert('泄漏:换说法的英文盘算能被宽松判据抓到', looksLikeLeakedReasoning(vague) === true, String(looksLikeLeakedReasoning(vague)))
114
- assert('泄漏:纯中文回答不会被宽松判据误判', looksLikeLeakedReasoning('我是 Omen Alpha,负责解释你选中的文字。') === false, '')
115
- }
116
-
117
- // ───────────────────────── 工具清单解析(联网工具的"备用"逻辑) ─────────────────────────
118
- {
119
- const preferred = ['advanced_search', 'platform_search', 'read', 'grep', 'glob']
120
- const fallback = ['web_search']
121
-
122
- // ① 装了 free-search:只用首选,不补 web_search
123
- const normal = resolveToolNames(preferred, fallback, ['advanced_search', 'platform_search', 'read', 'grep', 'glob', 'web_search', 'web_fetch'])
124
- assert('工具:首选联网工具在时不补 web_search', normal.includes('advanced_search') && normal.includes('platform_search') && !normal.includes('web_search'), JSON.stringify(normal))
125
- assert('工具:也没顺带把 web_fetch 拉进来', !normal.includes('web_fetch'), JSON.stringify(normal))
126
-
127
- // ② 没装 free-search(两个搜索工具都不在):补上 web_search
128
- const fallbackCase = resolveToolNames(preferred, fallback, ['read', 'grep', 'glob', 'web_search', 'web_fetch', 'bash'])
129
- assert('工具:没装联网插件时补上 web_search(小窗不至于变哑巴)', fallbackCase.includes('web_search'), JSON.stringify(fallbackCase))
130
- assert('工具:备用只补联网类,不会把 bash 之类带进来', !fallbackCase.includes('bash') && !fallbackCase.includes('web_fetch'), JSON.stringify(fallbackCase))
131
-
132
- // ③ 备用名在会话里也不存在(理论上不会,但要幂等)
133
- const noneAtAll = resolveToolNames(preferred, fallback, ['read', 'grep'])
134
- assert('工具:备用名也不存在时,不报错、不产生幽灵工具', JSON.stringify(noneAtAll) === JSON.stringify(['read', 'grep']), JSON.stringify(noneAtAll))
135
-
136
- // ④ 只装了其中一个搜索工具 → 也算"有联网",不补
137
- const halfCase = resolveToolNames(preferred, fallback, ['platform_search', 'read', 'web_search'])
138
- assert('工具:只有 platform_search 时不补 web_search', halfCase.includes('platform_search') && !halfCase.includes('web_search'), JSON.stringify(halfCase))
139
-
140
- // ⑤ 去重:首选里已经写了 web_search 时,不能出现两次
141
- const dupCase = resolveToolNames(['web_search', ...preferred], fallback, ['web_search', 'read'])
142
- assert('工具:清单不出现重复项', dupCase.filter((n) => n === 'web_search').length === 1, JSON.stringify(dupCase))
143
- }
144
-
145
- console.log(`\n=== 过滤器单测结束:${pass} 通过 / ${fail} 失败 ===`)
146
- process.exit(fail === 0 ? 0 : 1)
@@ -1,93 +0,0 @@
1
- /**
2
- * 会话格式契约:**写出去的那几个 shape 必须是当前内核认的**。
3
- *
4
- * 为什么单独一个文件:升格(`↗ 升格`)是往会话日志里**写**事件,写完由内核按
5
- * 会话格式做准入校验 —— 校验发生在**真实内核跑起来的那一刻**,不在构建期,
6
- * 所以离线测试全绿也照样能在用户机器上炸。v0.7.0 就是这么坏的:
7
- *
8
- * `source: { kind: 'plugin', plugin: name }` 是会话格式 v3 的写法;
9
- * v4 起该包装已退役,准入直接抛
10
- * format v4 message requires a producer-owned source kind
11
- * 表现:升格请求返回 `ok: true`(只写进了内存里的会话面)→ 随后那轮 opener
12
- * 「本轮运行失败」→ 整个 artifact 一个字都没落盘,新会话只剩表头。
13
- *
14
- * 这里钉住两件**离线可判**的事实(都是当初真正踩坏的):
15
- * ① 上下文消息的 source.kind 是生产者自有的(`plugin:<插件名>`:非空、非裸 'plugin'),
16
- * 且真的被用在 `form: 'snapshot'` 的上下文消息上(不是写了个没人用的常量);
17
- * ② 构建产物里不再出现退役的 plugin 包装,以及升格那套 turn/step 骨架还在
18
- * (骨架丢了消息就没有坐标,客户端不会把它显示成历史对话)。
19
- *
20
- * 跑法:node scripts/test-format.mjs(已挂进 npm test)
21
- */
22
- import { readFileSync } from 'node:fs'
23
- import { fileURLToPath } from 'node:url'
24
- import { dirname, resolve } from 'node:path'
25
- import { PLUGIN_SOURCE_KIND, name } from '../lib/index.js'
26
-
27
- const HERE = dirname(fileURLToPath(import.meta.url))
28
- const BUNDLE = readFileSync(resolve(HERE, '..', 'lib', 'index.js'), 'utf8')
29
-
30
- let pass = 0
31
- let fail = 0
32
- const assert = (label, ok, detail) => {
33
- if (ok) {
34
- pass += 1
35
- console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`)
36
- } else {
37
- fail += 1
38
- console.log(`FAIL ${label}${detail ? ` — ${detail}` : ''}`)
39
- }
40
- }
41
-
42
- // ───────────────────────── ① 来源归属(v4 准入的那条线) ─────────────────────────
43
- assert(
44
- '来源归属:上下文消息的 kind 与「插件名」一致(plugin:<插件名>)',
45
- PLUGIN_SOURCE_KIND === `plugin:${name}`,
46
- JSON.stringify({ PLUGIN_SOURCE_KIND, name }),
47
- )
48
- assert(
49
- '来源归属:kind 是生产者自有的(非空、非裸 plugin)',
50
- typeof PLUGIN_SOURCE_KIND === 'string' && PLUGIN_SOURCE_KIND.length > 'plugin:'.length && PLUGIN_SOURCE_KIND !== 'plugin',
51
- JSON.stringify(PLUGIN_SOURCE_KIND),
52
- )
53
- assert(
54
- '来源归属:第三方插件带 `plugin:` 命名空间(与内核 v3→v4 迁移的写法一致)',
55
- PLUGIN_SOURCE_KIND.startsWith('plugin:'),
56
- JSON.stringify(PLUGIN_SOURCE_KIND),
57
- )
58
-
59
- // ───────────────────────── ② 真的用上了(不是写了没人用的常量) ─────────────────────────
60
- const used = BUNDLE.indexOf('kind: PLUGIN_SOURCE_KIND')
61
- assert('使用点:源码里真的以 PLUGIN_SOURCE_KIND 作为 kind 写出消息', used >= 0, used >= 0 ? `偏移 ${used}` : '构建产物里找不到')
62
- {
63
- // 同一个 source 字面量里:kind 之后要跟着 snapshot 呈现(客户端据此折叠成「上下文」行)
64
- const window = used >= 0 ? BUNDLE.slice(used, used + 240) : ''
65
- assert('使用点:同一条 source 带 `form: \'snapshot\'`(客户端才折叠成上下文行)', /form:\s*'snapshot'/.test(window), window ? JSON.stringify(window.slice(0, 80)) : '')
66
- assert('使用点:同一条 source 带 sections(snapshot 的命名分节,模型读的就是这些字)', /sections:\s*\[/.test(window), '')
67
- }
68
-
69
- // ───────────────────────── ③ 退役写法不再出现 ─────────────────────────
70
- {
71
- // v3 的 plugin 包装:kind:'plugin' 后面紧跟一个 plugin 字段。
72
- // 注意这是**文本级**断言(连注释里都别写出那个形状)—— 宁可误报,也别漏报:
73
- // 这正是本次回归的形态,写进注释里也会把将来的人带沟里。
74
- const retired = /kind:\s*(["'])plugin\1\s*,\s*plugin\s*:/.exec(BUNDLE)
75
- assert(
76
- '退役写法:构建产物里没有 kind: plugin + plugin 字段的包装(v4 会当场拒掉)',
77
- retired === null,
78
- retired ? `offset ${retired.index}:${JSON.stringify(retired[0])}` : '',
79
- )
80
- }
81
-
82
- // ───────────────────────── ④ 升格那套 turn/step 骨架还在 ─────────────────────────
83
- {
84
- // 客户端按 turn/step 事件给消息定位;少一个,历史消息就没有坐标、不显示成对话
85
- const skeleton = ['turn/start', 'step/start', 'user/message', 'assistant/message', 'step/end', 'turn/end']
86
- const missing = skeleton.filter((type) => !BUNDLE.includes(`'${type}'`))
87
- assert('升格骨架:turn/step 六个事件都还写着', missing.length === 0, missing.length ? `缺:${missing.join(', ')}` : '')
88
- assert('升格骨架:turn/end 带 reason(否则那轮不算收尾)', /reason:\s*\{\s*kind:\s*'completed'/.test(BUNDLE), '')
89
- assert('升格骨架:历史轮号从 1000 起(避开真实第一轮的号段)', /turnNo\s*=\s*1000/.test(BUNDLE), '')
90
- }
91
-
92
- console.log(`\n=== 会话格式契约测试结束:${pass} 通过 / ${fail} 失败 ===`)
93
- process.exit(fail === 0 ? 0 : 1)
@@ -1,48 +0,0 @@
1
- /**
2
- * 文件工具边界(isInsideRoot)离线测试。
3
- *
4
- * 为什么要有这条测试:read/grep/glob 能读用户有权读的**任何**文件,
5
- * 而划词的触发源(选中文字/上下文/搜索结果)可能是别人写的。
6
- * 边界判断一旦写错(比如前缀匹配写成 `startsWith(root)`),
7
- * `/Users/me/project-evil` 就会被误判成在 `/Users/me/project` 之内。
8
- *
9
- * 用法:node scripts/test-guard.mjs
10
- */
11
- import { isInsideRoot } from '../lib/index.js'
12
-
13
- let pass = 0
14
- let fail = 0
15
- function check(name, actual, expected) {
16
- const ok = actual === expected
17
- if (ok) pass += 1
18
- else fail += 1
19
- console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — 期望 ${String(expected)},实际 ${String(actual)}`)
20
- }
21
-
22
- const ROOT = '/Users/me/project'
23
-
24
- // 不限(root 为空):一切都放行
25
- check('root 为空时放行相对路径', isInsideRoot('', 'a.txt'), true)
26
- check('root 为空时放行绝对路径', isInsideRoot('', '/etc/hosts'), true)
27
-
28
- // 项目内
29
- check('项目内的相对路径', isInsideRoot(ROOT, 'src/index.ts'), true)
30
- check('项目内的绝对路径', isInsideRoot(ROOT, '/Users/me/project/src/a.ts'), true)
31
- check('项目根目录本身', isInsideRoot(ROOT, '/Users/me/project'), true)
32
- check('带 .. 但仍在项目内', isInsideRoot(ROOT, 'src/../README.md'), true)
33
-
34
- // 项目外
35
- check('父目录', isInsideRoot(ROOT, '../secrets.txt'), false)
36
- check('绝对路径指向项目外', isInsideRoot(ROOT, '/etc/hosts'), false)
37
- check('家目录', isInsideRoot(ROOT, '/Users/me/.ssh/id_rsa'), false)
38
- check('~ 开头一律拒', isInsideRoot(ROOT, '~/.dsh/settings.yaml'), false)
39
- check('单独一个 ~', isInsideRoot(ROOT, '~'), false)
40
- // 前缀陷阱:同名兄弟目录不能算"在项目内"
41
- check('前缀相同的兄弟目录不算项目内', isInsideRoot(ROOT, '/Users/me/project-evil/x'), false)
42
- check('前缀相同的兄弟文件不算项目内', isInsideRoot(ROOT, '/Users/me/project.bak'), false)
43
-
44
- // 没给路径(grep/glob 的 path 是可选的,默认 = 会话工作目录)→ 放行
45
- check('未给 path 时放行(走默认工作目录)', isInsideRoot(ROOT, ''), true)
46
-
47
- console.log(`\n=== 边界测试结束:${pass} 通过 / ${fail} 失败 ===`)
48
- process.exit(fail === 0 ? 0 : 1)