@yfwu2020/dsh-selection-explain 0.10.4 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +13 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +10698 -12424
  4. package/lib/client.js.map +7 -0
  5. package/lib/config-values.js +55 -0
  6. package/lib/config-values.js.map +1 -0
  7. package/lib/host/analyze.js +795 -0
  8. package/lib/host/analyze.js.map +1 -0
  9. package/lib/host/config.js +81 -0
  10. package/lib/host/config.js.map +1 -0
  11. package/lib/host/constants.js +25 -0
  12. package/lib/host/constants.js.map +1 -0
  13. package/lib/host/context.js +237 -0
  14. package/lib/host/context.js.map +1 -0
  15. package/lib/host/design-skill.js +42 -0
  16. package/lib/host/design-skill.js.map +1 -0
  17. package/lib/host/effort.js +239 -0
  18. package/lib/host/effort.js.map +1 -0
  19. package/lib/host/filters.js +132 -0
  20. package/lib/host/filters.js.map +1 -0
  21. package/lib/host/history-store.js +404 -0
  22. package/lib/host/history-store.js.map +1 -0
  23. package/lib/host/http.js +46 -0
  24. package/lib/host/http.js.map +1 -0
  25. package/lib/host/messages.js +14 -0
  26. package/lib/host/messages.js.map +1 -0
  27. package/lib/host/mic.js +246 -0
  28. package/lib/host/mic.js.map +1 -0
  29. package/lib/host/models.js +246 -0
  30. package/lib/host/models.js.map +1 -0
  31. package/lib/host/plugin.js +41 -0
  32. package/lib/host/plugin.js.map +1 -0
  33. package/lib/host/promote.js +219 -0
  34. package/lib/host/promote.js.map +1 -0
  35. package/lib/host/routes.js +237 -0
  36. package/lib/host/routes.js.map +1 -0
  37. package/lib/host/sessions.js +181 -0
  38. package/lib/host/sessions.js.map +1 -0
  39. package/lib/host/settings-store.js +140 -0
  40. package/lib/host/settings-store.js.map +1 -0
  41. package/lib/host/speech.js +320 -0
  42. package/lib/host/speech.js.map +1 -0
  43. package/lib/host/tools.js +179 -0
  44. package/lib/host/tools.js.map +1 -0
  45. package/lib/host/types.js +2 -0
  46. package/lib/host/types.js.map +1 -0
  47. package/lib/index.js +10 -3765
  48. package/lib/index.js.map +1 -1
  49. package/lib/prompts.js +1 -1
  50. package/lib/settings.js +17 -27
  51. package/lib/settings.js.map +1 -1
  52. package/lib/types/config-values.d.ts +175 -0
  53. package/lib/types/host/analyze.d.ts +10 -0
  54. package/lib/types/host/config.d.ts +147 -0
  55. package/lib/types/host/constants.d.ts +24 -0
  56. package/lib/types/host/context.d.ts +41 -0
  57. package/lib/types/host/design-skill.d.ts +1 -0
  58. package/lib/types/host/effort.d.ts +61 -0
  59. package/lib/types/host/filters.d.ts +20 -0
  60. package/lib/types/host/history-store.d.ts +13 -0
  61. package/lib/types/host/http.d.ts +8 -0
  62. package/lib/types/host/messages.d.ts +13 -0
  63. package/lib/types/host/mic.d.ts +17 -0
  64. package/lib/types/host/models.d.ts +57 -0
  65. package/lib/types/host/plugin.d.ts +3 -0
  66. package/lib/types/host/promote.d.ts +9 -0
  67. package/lib/types/host/routes.d.ts +14 -0
  68. package/lib/types/host/sessions.d.ts +14 -0
  69. package/lib/types/host/settings-store.d.ts +8 -0
  70. package/lib/types/host/speech.d.ts +23 -0
  71. package/lib/types/host/tools.d.ts +39 -0
  72. package/lib/types/host/types.d.ts +89 -0
  73. package/lib/types/index.d.ts +12 -441
  74. package/lib/types/prompts.d.ts +1 -1
  75. package/lib/types/settings.d.ts +1 -1
  76. package/package.json +31 -21
  77. package/assets/code.png +0 -0
  78. package/assets/detail.png +0 -0
  79. package/assets/quotes.png +0 -0
  80. package/assets/stage1.png +0 -0
  81. package/assets/stage2.png +0 -0
  82. package/assets/translation.png +0 -0
  83. package/assets/voice-card-light.png +0 -0
  84. package/assets/voice-inserted.png +0 -0
  85. package/assets/voice-settings.png +0 -0
  86. package/assets/voice.png +0 -0
  87. package/assets/web.png +0 -0
  88. package/scripts/build-demo-pages.mjs +0 -350
  89. package/scripts/build-vcard-lab.mjs +0 -834
  90. package/scripts/build.sh +0 -147
  91. package/scripts/crop-png.mjs +0 -142
  92. package/scripts/dump-prompt.mjs +0 -143
  93. package/scripts/fixtures/fake-mic-probe.sh +0 -9
  94. package/scripts/gen-star-path.mjs +0 -71
  95. package/scripts/measure-cost.mjs +0 -251
  96. package/scripts/smoke-bridge.mjs +0 -365
  97. package/scripts/smoke-panel.mjs +0 -1903
  98. package/scripts/smoke-voice.mjs +0 -774
  99. package/scripts/test-bridge.mjs +0 -412
  100. package/scripts/test-client.mjs +0 -6175
  101. package/scripts/test-effort-map.mjs +0 -129
  102. package/scripts/test-effort-retry.mjs +0 -109
  103. package/scripts/test-escape-focus.mjs +0 -101
  104. package/scripts/test-filters.mjs +0 -146
  105. package/scripts/test-format.mjs +0 -93
  106. package/scripts/test-guard.mjs +0 -48
  107. package/scripts/test-mic-build.mjs +0 -146
  108. package/scripts/test-mic-idle.mjs +0 -158
  109. package/scripts/test-model-follow.mjs +0 -193
  110. package/scripts/test-prompt.mjs +0 -193
  111. package/scripts/test-route-cache.mjs +0 -138
  112. package/scripts/test-settings-behavior.mjs +0 -188
  113. package/scripts/test-settings-host.mjs +0 -48
  114. package/scripts/test-settings.mjs +0 -329
  115. package/scripts/test-speech.mjs +0 -220
  116. package/scripts/test-transcript.mjs +0 -151
  117. package/src/client/index.js +0 -12580
  118. package/src/index.ts +0 -4254
  119. package/src/prompts.ts +0 -144
  120. package/src/settings.ts +0 -313
@@ -1,146 +0,0 @@
1
- /**
2
- * 「就地编译探针」:npm / Release 装下来的包里**没有** `lib/native/mic-probe`
3
- * —— CI 跑在 ubuntu 上,`build.sh` 里那一步会被跳过(日志原话:
4
- * "跳过原生探针(非 macOS 或没有 clang)"),所以包里只有 `native/mic-probe.c` 源码。
5
- *
6
- * 后果(2026-10-11 用户实测):从 npm 装到别的 Mac 上,豆包开麦永远不弹卡 ——
7
- * host 直接报 `probe-missing`。修法是第一次有人问麦克风时**用 clang 现场编一份**,
8
- * 按源码 hash 缓存进数据目录。
9
- *
10
- * 这条用例就复现"装包的机器":把随包的二进制**暂时挪开**、数据目录指向一个全新的
11
- * DSH_HOME,然后问一次 /mic —— 应该 available:true,而且编出来的二进制真能跑(--once)。
12
- *
13
- * 只在 macOS + 有 clang 时有意义;其它平台 SKIP(与真浏览器冒烟同一套约定:跑不了就跳过)。
14
- *
15
- * 用法:node scripts/test-mic-build.mjs
16
- */
17
- import { createServer } from 'node:http'
18
- import { existsSync } from 'node:fs'
19
- import { mkdtemp, readdir, rename, rm } from 'node:fs/promises'
20
- import { spawnSync } from 'node:child_process'
21
- import { tmpdir } from 'node:os'
22
- import { join } from 'node:path'
23
- import { fileURLToPath } from 'node:url'
24
-
25
- if (process.platform !== 'darwin' || spawnSync('clang', ['--version']).status !== 0) {
26
- console.log('SKIP 就地编译探针是 macOS 专用(且需要 clang)—— 本机跳过')
27
- process.exit(0)
28
- }
29
-
30
- const root = fileURLToPath(new URL('..', import.meta.url))
31
- const packaged = join(root, 'lib/native/mic-probe')
32
- const stash = packaged + '.probe-build-test'
33
- const home = await mkdtemp(join(tmpdir(), 'dsh-mic-build-'))
34
- // 数据目录与"注入了假探针"那条路都要避开:前者要全新,后者要显式删除(否则测不到编译)
35
- process.env.DSH_HOME = home
36
- delete process.env.DSH_SEL_MIC_PROBE
37
-
38
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
39
- let passed = 0
40
- let failed = 0
41
- function check(label, ok, detail) {
42
- if (ok) {
43
- passed += 1
44
- console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`)
45
- } else {
46
- failed += 1
47
- console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`)
48
- }
49
- }
50
-
51
- let moved = false
52
- if (existsSync(packaged)) {
53
- await rename(packaged, stash)
54
- moved = true
55
- }
56
-
57
- const routes = new Map()
58
- const disposers = []
59
- const ctx = {
60
- llm: { listProviders: () => [] },
61
- get: () => undefined,
62
- on: () => () => {},
63
- effect: (fn) => {
64
- const dispose = fn()
65
- disposers.push(dispose)
66
- return dispose
67
- },
68
- logger: { info() {}, warn() {} },
69
- webServer: {
70
- register: (route) => {
71
- routes.set(route.path, route.handler)
72
- return () => routes.delete(route.path)
73
- },
74
- },
75
- }
76
-
77
- try {
78
- // ⚠️ 必须在"挪开二进制"之后才 import:探针路径是在模块求值时算的
79
- const { apply } = await import('../lib/index.js')
80
- apply(ctx, {})
81
- const server = createServer((req, res) => {
82
- const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
83
- if (!handler) {
84
- res.writeHead(404)
85
- res.end()
86
- return
87
- }
88
- Promise.resolve(handler(req, res)).catch(() => {
89
- if (!res.headersSent) res.writeHead(500)
90
- res.end()
91
- })
92
- })
93
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
94
- const origin = 'http://127.0.0.1:' + server.address().port
95
- const dataDir = join(home, 'selection-explain')
96
-
97
- check('(前提)装包的样子:随包的二进制不在,只剩源码', !existsSync(packaged) && existsSync(join(root, 'native/mic-probe.c')), '')
98
-
99
- const payload = await (await fetch(`${origin}/selection-explain/api/mic`)).json()
100
- check(
101
- '装包的机器上第一次问麦克风 → 就地编出来,状态是 available(不再 probe-missing)',
102
- payload.ok === true && payload.available === true && payload.reason === '',
103
- JSON.stringify({ available: payload.available, reason: payload.reason }),
104
- )
105
-
106
- const files = existsSync(dataDir) ? await readdir(dataDir) : []
107
- const compiled = files.filter((f) => /^mic-probe-[0-9a-f]{8}$/.test(f))
108
- check(
109
- '编出来的探针按源码 hash 缓存进数据目录(源码改了会自然重建)',
110
- compiled.length === 1,
111
- JSON.stringify(files),
112
- )
113
-
114
- if (compiled.length === 1) {
115
- const run = spawnSync(join(dataDir, compiled[0]), ['--once'], { encoding: 'utf8', timeout: 20_000 })
116
- let parsed = null
117
- try {
118
- parsed = JSON.parse(String(run.stdout || '').trim().split('\n').pop() || 'null')
119
- } catch {
120
- /* 保持 null */
121
- }
122
- check(
123
- '编出来的二进制真能跑(--once 输出一行合法 JSON)',
124
- run.status === 0 && !!parsed && typeof parsed.capturing === 'boolean',
125
- JSON.stringify({ status: run.status, stdout: String(run.stdout || '').trim().slice(0, 80) }),
126
- )
127
- } else {
128
- check('编出来的二进制真能跑(--once 输出一行合法 JSON)', false, '没有找到编出来的探针')
129
- }
130
-
131
- server.close()
132
- } finally {
133
- for (const dispose of disposers) {
134
- try {
135
- dispose()
136
- } catch {
137
- /* 忽略 */
138
- }
139
- }
140
- await sleep(300)
141
- if (moved) await rename(stash, packaged)
142
- await rm(home, { recursive: true, force: true })
143
- }
144
-
145
- console.log(`\n探针就地编译:${passed} 通过 / ${failed} 失败`)
146
- process.exit(failed ? 1 : 0)
@@ -1,158 +0,0 @@
1
- /**
2
- * 麦克风探针的生命周期:划词即启动 → 没人看就停 → 再划词再启动。
3
- *
4
- * 为什么单测它:探针是个常驻原生进程(每秒读 4 次 CoreAudio),原来一旦启动就留到插件卸载 ——
5
- * 而插件装着的绝大多数时间没人划词,等于白跑。现在跟着页面的轮询走:
6
- * 页面只在"有选区 / 卡片开着"时轮询,探针也就只在那时活着。
7
- *
8
- * 验三条:
9
- * ① 有人来问才起(之前一直没人问 → 不起);
10
- * ② 一直有人问 → **不会**停(划词期间不能抖);
11
- * ③ 没人问满 MIC_IDLE_MS → 停,且状态回落 available=false(不假装还在听);
12
- * ④ 停了之后再来问 → 能重新起(micStarted 必须复位,否则第二次永远起不来)。
13
- *
14
- * 用法:node scripts/test-mic-idle.mjs
15
- */
16
- import { createServer } from 'node:http'
17
- import { mkdtemp, readFile, rm } from 'node:fs/promises'
18
- import { tmpdir } from 'node:os'
19
- import { join } from 'node:path'
20
- import { fileURLToPath } from 'node:url'
21
-
22
- // ⚠️ 这个功能**只在 macOS 上有**:探针读的是 CoreAudio(宿主在非 darwin 上直接报
23
- // available=false,见 src/index.ts 的平台门禁)。别的平台上这些用例必然全红 ——
24
- // 与真浏览器冒烟同一套约定:**跑不了就 SKIP、退出码 0**,别把发布流水线卡在一台
25
- // 没有这个能力的机器上(2026-10-10 实测:ubuntu CI 上 6/7 条红,v0.9.1 那次 CI 之后
26
- // 才把本用例加进 npm test 链,所以这是第一次真跑到 Linux 上)。
27
- if (process.platform !== 'darwin') {
28
- console.log('SKIP 麦克风探针是 macOS 专属(宿主在非 darwin 上直接报 available=false)—— 本机跳过')
29
- process.exit(0)
30
- }
31
-
32
- const dir = await mkdtemp(join(tmpdir(), 'dsh-mic-idle-'))
33
- const logPath = join(dir, 'probe.log')
34
- // ⚠️ 环境变量必须在 **import 之前** 设好:宿主那几个常量是在模块求值时读的,
35
- // 而 ESM 的 import 会被提升到测试体之前 —— 直接 import 的话,这里的赋值就晚了。
36
- process.env.DSH_SEL_FAKE_PROBE_LOG = logPath
37
- process.env.DSH_SEL_MIC_PROBE = fileURLToPath(new URL('./fixtures/fake-mic-probe.sh', import.meta.url))
38
- process.env.DSH_SEL_MIC_IDLE_MS = '150'
39
- process.env.DSH_SEL_MIC_CHECK_MS = '40'
40
- const { apply } = await import('../lib/index.js')
41
-
42
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
43
- const logText = async () => {
44
- try {
45
- return await readFile(logPath, 'utf8')
46
- } catch {
47
- return ''
48
- }
49
- }
50
- const starts = async () => ((await logText()).match(/start/g) || []).length
51
- const stops = async () => ((await logText()).match(/stop/g) || []).length
52
-
53
- const routes = new Map()
54
- const disposers = []
55
- const ctx = {
56
- llm: { listProviders: () => [] },
57
- get: () => undefined,
58
- on: () => () => {},
59
- effect: (fn) => {
60
- const dispose = fn()
61
- disposers.push(dispose)
62
- return dispose
63
- },
64
- logger: { info() {}, warn() {} },
65
- webServer: {
66
- register: (route) => {
67
- routes.set(route.path, route.handler)
68
- return () => routes.delete(route.path)
69
- },
70
- },
71
- }
72
-
73
- let passed = 0
74
- let failed = 0
75
- function check(label, ok, detail) {
76
- if (ok) {
77
- passed += 1
78
- console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`)
79
- } else {
80
- failed += 1
81
- console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`)
82
- }
83
- }
84
-
85
- try {
86
- apply(ctx, {})
87
- const server = createServer((req, res) => {
88
- const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
89
- if (!handler) {
90
- res.writeHead(404)
91
- res.end()
92
- return
93
- }
94
- Promise.resolve(handler(req, res)).catch(() => {
95
- if (!res.headersSent) res.writeHead(500)
96
- res.end()
97
- })
98
- })
99
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
100
- const origin = 'http://127.0.0.1:' + server.address().port
101
- const ask = async () => (await fetch(`${origin}/selection-explain/api/mic`)).json()
102
-
103
- // ① 没人问的时候,探针不该存在
104
- await sleep(120)
105
- check('没人来问 → 探针不启动', (await starts()) === 0, 'start 次数=' + (await starts()))
106
-
107
- // ② 有人问 → 起
108
- const first = await ask()
109
- await sleep(120)
110
- check(
111
- '第一次来问 → 探针启动,且状态是 available',
112
- first.ok === true && first.available === true && (await starts()) === 1,
113
- `available=${first.available} start=${await starts()}`,
114
- )
115
-
116
- check(
117
- '探针以约定的采样间隔启动(--watch 125:发现"开麦"的延迟减半)',
118
- (await logText()).includes('--watch 125'),
119
- JSON.stringify((await logText()).split('\n')[0]),
120
- )
121
-
122
- // ③ 一直有人问 → 不停(划词期间不能抖)
123
- for (let i = 0; i < 5; i += 1) {
124
- await ask()
125
- await sleep(40)
126
- }
127
- check('持续有人问 → 探针不重启也不停', (await starts()) === 1 && (await stops()) === 0, `start=${await starts()} stop=${await stops()}`)
128
-
129
- // ④ 没人问满 IDLE → 停
130
- await sleep(600)
131
- check('没人问满 MIC_IDLE_MS → 探针停掉', (await stops()) === 1, 'stop 次数=' + (await stops()))
132
-
133
- // ⑤ 再来问 → 能重新起(micStarted 必须被复位,否则第二次永远起不来)
134
- const again = await ask()
135
- await sleep(160)
136
- check(
137
- '再划词 → 探针重新启动',
138
- again.available === true && (await starts()) === 2,
139
- `available=${again.available} start 次数=${await starts()}`,
140
- )
141
-
142
- server.close()
143
- } finally {
144
- for (const dispose of disposers) {
145
- try {
146
- dispose()
147
- } catch {
148
- /* 忽略 */
149
- }
150
- }
151
- await sleep(400)
152
- const after = await stops()
153
- check('插件卸载 → 探针被停掉', after >= 1, 'stop 次数=' + after)
154
- await rm(dir, { recursive: true, force: true })
155
- }
156
-
157
- console.log(`\n麦克风探针生命周期:${passed} 通过 / ${failed} 失败`)
158
- process.exit(failed ? 1 : 0)
@@ -1,193 +0,0 @@
1
- /** Model following regressions: execute the client functions against real HTTP responses. */
2
- import assert from 'node:assert/strict'
3
- import { readFileSync } from 'node:fs'
4
- import { createServer } from 'node:http'
5
- import { resolve } from 'node:path'
6
- import { test } from 'node:test'
7
-
8
- const source = readFileSync(resolve(process.argv[2] || 'src/client/index.js'), 'utf8')
9
-
10
- // Extract executable functions, never assert on their spelling or implementation.
11
- function functionSource(name) {
12
- const start = source.indexOf('function ' + name + '(')
13
- assert.ok(start >= 0, 'Missing client function: ' + name)
14
- let depth = 0
15
- for (let i = source.indexOf('{', start); i < source.length; i += 1) {
16
- const ch = source[i]
17
- if (ch === '{') depth += 1
18
- else if (ch === '}' && --depth === 0) return source.slice(start, i + 1)
19
- else if (ch === "'" || ch === '"' || ch === '`') {
20
- for (i += 1; i < source.length; i += 1) {
21
- if (source[i] === '\\') i += 1
22
- else if (source[i] === ch) break
23
- }
24
- } else if (ch === '/' && source[i + 1] === '/') {
25
- while (i < source.length && source[i] !== '\n') i += 1
26
- } else if (ch === '/' && source[i + 1] === '*') {
27
- i = source.indexOf('*/', i) + 1
28
- }
29
- }
30
- throw new Error('Unclosed function: ' + name)
31
- }
32
-
33
- function currentSession(services) {
34
- return new Function('ctx', functionSource('currentSessionId') + '; return currentSessionId()')({
35
- get: (name) => services[name],
36
- })
37
- }
38
-
39
- test('reads the current session from the modern DSH session binding, not catalog.current', () => {
40
- assert.equal(currentSession({
41
- uiSession: { adapter: { current: { getSnapshot: () => ({ key: 'session-a', props: { sessionId: 'session-a' } }) } } },
42
- sessions: { list: { getSnapshot: () => ({ ids: ['session-a'], byId: {}, phase: 'ready', projectionsBySession: {} }) } },
43
- }), 'session-a')
44
- })
45
-
46
- test('an absent modern binding cannot revive a stale legacy current session', () => {
47
- assert.equal(currentSession({
48
- uiSession: { adapter: { current: { getSnapshot: () => ({ key: undefined, props: { sessionId: undefined } }) } } },
49
- sessions: { list: { getSnapshot: () => ({ current: 'stale-session' }) } },
50
- }), '')
51
- })
52
-
53
- test('supports older DSH runtimes without the session binding service', () => {
54
- assert.equal(currentSession({ sessions: { list: { getSnapshot: () => ({ current: 'legacy-session' }) } } }), 'legacy-session')
55
- })
56
-
57
- const server = createServer((req, res) => {
58
- const id = new URL(req.url, 'http://localhost').searchParams.get('sessionId')
59
- const send = () => {
60
- res.setHeader('content-type', 'application/json')
61
- res.end(JSON.stringify({ ok: true, current: { provider: 'provider', model: id }, models: [{ provider: 'provider', model: id }] }))
62
- }
63
- // A finishes after B, reproducing a switch while A's catalog is loading.
64
- setTimeout(send, id === 'session-a' ? 40 : 0)
65
- })
66
- await new Promise((done) => server.listen(0, '127.0.0.1', done))
67
- const origin = 'http://127.0.0.1:' + server.address().port
68
-
69
- function catalogClient(currentId) {
70
- return new Function('MODELS', 'fetch', 'currentSessionId', 'panelOrCurrentSessionId', `
71
- var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, pending: null, error: '', leakyOff: [], sessionId: null }
72
- var modelCatalogRequestSeq = 0
73
- ${functionSource('loadModelCatalog')}
74
- return { load: loadModelCatalog, snapshot: () => modelCatalog }
75
- `)(origin + '/models', fetch, () => currentId, () => currentId)
76
- }
77
-
78
- try {
79
- await test('catalog requests use the panel session even after navigation to another session', async () => {
80
- const client = catalogClient('session-b')
81
- const result = await client.load(true, 'session-a')
82
- assert.equal(result.current.model, 'session-a')
83
- assert.equal(result.sessionId, 'session-a')
84
- })
85
-
86
- await test('concurrent catalogs for different sessions cannot share or overwrite each other', async () => {
87
- const client = catalogClient('session-a')
88
- const a = client.load(true, 'session-a')
89
- const b = client.load(true, 'session-b')
90
- const [resultA, resultB] = await Promise.all([a, b])
91
- assert.equal(resultA.current.model, 'session-a')
92
- assert.equal(resultB.current.model, 'session-b')
93
- assert.equal(client.snapshot().current.model, 'session-b')
94
- const cachedB = await client.load(false, 'session-b')
95
- assert.equal(cachedB.current.model, 'session-b')
96
- })
97
- } finally {
98
- server.closeAllConnections()
99
- await new Promise((done) => server.close(done))
100
- }
101
-
102
- await test('legacy follow-up model cannot override the model used by first and second rounds', async () => {
103
- const ts = await import('typescript')
104
- const host = readFileSync(resolve('src/index.ts'), 'utf8')
105
- const start = host.indexOf(' const resolveRoute = (')
106
- const end = host.indexOf(' /** 分钟级限流。 */', start)
107
- const js = ts.default.transpileModule(host.slice(start, end), { compilerOptions: { target: ts.default.ScriptTarget.ES2022 } }).outputText
108
- const config = { provider: 'shared', model: 'chosen', chatProvider: 'legacy', chatModel: 'wrong' }
109
- const route = new Function('config', 'modelCache', 'ctx', 'captured', js + '; return resolveRoute')(config, { items: [] }, { get: () => null }, null)
110
- assert.deepEqual(route(undefined, null, false), { provider: 'shared', model: 'chosen' })
111
- assert.deepEqual(route(undefined, null, true), { provider: 'shared', model: 'chosen' })
112
- config.provider = ''; config.model = ''
113
- assert.deepEqual(route(undefined, { provider: 'session', model: 'current' }, true), { provider: 'session', model: 'current' })
114
- })
115
-
116
- await test('first and second rounds use the same selected model without borrowing follow-up effort', () => {
117
- const payload = new Function('state', 'document', 'location', 'panelOrCurrentSessionId', 'selectionKind', functionSource('buildPayload') + '; return buildPayload')(
118
- { modelChoice: { provider: 'shared', model: 'chosen' }, effort: 'max' }, { title: 'test' }, { href: 'http://local' }, () => 'session', () => 'text')
119
- for (const stage of ['translation', 'detail']) {
120
- const body = payload('text', 'context', 'label', false, stage)
121
- assert.equal(body.provider, 'shared')
122
- assert.equal(body.model, 'chosen')
123
- assert.equal(body.effort, undefined)
124
- }
125
- })
126
-
127
- function settingsClient(send) {
128
- return new Function('fetch', `
129
- var SETTINGS = '/settings', settingsSpec = {}, settingsSaving = false, settingsResetting = false,
130
- settingsSaveRequest = null, settingsSaveSeq = 0, settingsSaveTimer = 0,
131
- settingsLive = {provider: 'p', model: 'old'}, settingsSaved = {provider: 'p', model: 'old'},
132
- settingsByKey = {}, state = {}, bridgeOn = true, EFFORT_KEY = 'effort', scheduled = 0, MAX_SELECTION = 4000;
133
- var settingsNoteText = {textContent: ''}, settingsNote = {setAttribute: function(){}};
134
- function renderSettingsNote(note) { settingsNoteText.textContent = note === 'error' ? '保存失败,改动未生效' : note }
135
- function settingsDirtyCount() { return Object.keys(settingsLive).filter(k => settingsLive[k] !== settingsSaved[k]).length }
136
- function scheduleSettingsSave() { scheduled++ }
137
- function paintModelPick(){} function repaintModelMenu(){} function setControlValue(){}
138
- function updateSettingDependencies(){} function syncComposerModel(){ state.modelChoice = settingsLive.model ? {provider: settingsLive.provider, model: settingsLive.model} : null }
139
- function refreshModelRow(){} function paintModelPill(){} function writeStore(){} function applyPillPrefs(){} function scheduleBridgeScan(){}
140
- ${functionSource('applySelectionLimit')}
141
- ${functionSource('applyServerValues')}
142
- ${functionSource('flushSettingsSave')}
143
- ${functionSource('resetSettings')}
144
- return { edit(model) { settingsLive.model = model }, save: flushSettingsSave, reset: resetSettings,
145
- snapshot() { return {live: {...settingsLive}, saved: {...settingsSaved}, scheduled, resetting: settingsResetting, choice: state.modelChoice} } }
146
- `)(send)
147
- }
148
-
149
- await test('a slow save preserves a newer model choice and schedules its save', async () => {
150
- let finish
151
- const client = settingsClient(() => new Promise(resolve => { finish = resolve }))
152
- client.edit('first')
153
- const pending = client.save()
154
- client.edit('second')
155
- finish({json: async () => ({ok: true, values: {provider: 'p', model: 'first'}})})
156
- await pending
157
- assert.equal(client.snapshot().live.model, 'second')
158
- assert.equal(client.snapshot().saved.model, 'first')
159
- assert.equal(client.snapshot().choice.model, 'second')
160
- assert.equal(client.snapshot().scheduled, 1)
161
- })
162
-
163
- await test('reset waits for an active save before clearing the shared model', async () => {
164
- let finish; const requests = []
165
- const client = settingsClient((url, init) => {
166
- const body = JSON.parse(init.body); requests.push(body)
167
- if (!body.reset) return new Promise(resolve => { finish = resolve })
168
- return Promise.resolve({json: async () => ({ok: true, values: {provider: '', model: '', chatReasoningEffort: 'high'}})})
169
- })
170
- client.edit('changed'); const pending = client.save(); client.reset()
171
- await Promise.resolve()
172
- assert.equal(requests.length, 1)
173
- finish({json: async () => ({ok: true, values: {provider: 'p', model: 'changed'}})})
174
- await pending
175
- for (let i = 0; i < 20 && client.snapshot().resetting; i++) await new Promise(resolve => setTimeout(resolve, 0))
176
- assert.equal(requests.length, 2)
177
- assert.equal(requests[1].reset, true)
178
- assert.equal(client.snapshot().live.model, '')
179
- assert.equal(client.snapshot().choice, null)
180
- })
181
-
182
- await test('reset also clears a value normalized by the in-flight save', async () => {
183
- let finish
184
- const client = settingsClient((url, init) => JSON.parse(init.body).reset
185
- ? Promise.resolve({json: async () => ({ok: true, values: {provider: '', model: ''}})})
186
- : new Promise(resolve => { finish = resolve }))
187
- client.edit(' padded '); const pending = client.save(); client.reset()
188
- finish({json: async () => ({ok: true, values: {provider: 'p', model: 'padded'}})})
189
- await pending
190
- for (let i = 0; i < 20 && client.snapshot().resetting; i++) await new Promise(resolve => setTimeout(resolve, 0))
191
- assert.equal(client.snapshot().live.model, '')
192
- assert.equal(client.snapshot().scheduled, 0)
193
- })