@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,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
- })
@@ -1,193 +0,0 @@
1
- /**
2
- * 提示词契约测试 —— 保护提示词里的**关键约束**不被静默删除。
3
- *
4
- * 为什么需要它:提示词是产品行为的主要来源(两节结构、结论条、音标规则、来源判断、
5
- * 网页模式要求…),但它们只是字符串常量,改动时**没有类型检查也没有编译错误**。
6
- * 以前唯一的"验证"是手工看 PROMPTS.md,而那份快照会过期。这里把关键约束写成断言:
7
- * 谁误删了,CI 直接红。
8
- *
9
- * 读取方式:优先直接 import src/prompts.ts(Node ≥ 22.14 的类型剥离支持直接加载 .ts,
10
- * 与 GitHub Actions 的 Node 22.14 一致,无需编译步骤);如果运行环境的 Node 太旧、
11
- * 不支持类型剥离,则回退到"读源码文本 + 解析数组字面量",保证测试在任何 Node 上都能跑。
12
- */
13
- import { readFileSync } from 'node:fs'
14
- import { resolve, dirname } from 'node:path'
15
- import { fileURLToPath } from 'node:url'
16
-
17
- const HERE = dirname(fileURLToPath(import.meta.url))
18
- const PROMPTS_TS = resolve(HERE, '..', 'src', 'prompts.ts')
19
-
20
- const NAMES = [
21
- 'PROMPT_HEAD',
22
- 'TRANSLATION_PROMPT',
23
- 'CODE_PROMPT',
24
- 'DETAIL_PROMPT',
25
- 'CHAT_SYSTEM_PROMPT',
26
- ]
27
-
28
- /** 回退方案:从源码文本里解析 `export const X = [ '...', ... ].join('\n')`。 */
29
- function loadFromSource() {
30
- const src = readFileSync(PROMPTS_TS, 'utf8')
31
- const out = {}
32
- for (const name of NAMES) {
33
- const re = new RegExp(`export const ${name} = \\[([\\s\\S]*?)\\]\\.join\\('\\\\n'\\)`)
34
- const m = src.match(re)
35
- if (!m) throw new Error(`回退解析失败:找不到 ${name}`)
36
- const body = m[1]
37
- // 元素既可能是字符串字面量,也可能是别的常量(片段复用),后者需要展开
38
- const parts = []
39
- const reElem = /'((?:[^'\\]|\\.)*)'|([A-Z_]+)/g
40
- let em
41
- while ((em = reElem.exec(body)) !== null) {
42
- if (em[1] !== undefined) parts.push(em[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
43
- else parts.push(out[em[2]] ?? '')
44
- }
45
- out[name] = parts.join('\n')
46
- }
47
- return out
48
- }
49
-
50
- let P
51
- let loader = 'import src/prompts.ts(Node 类型剥离)'
52
- try {
53
- P = await import(PROMPTS_TS)
54
- } catch (error) {
55
- loader = `源码文本解析回退(类型剥离不可用:${error && error.message})`
56
- P = loadFromSource()
57
- }
58
- const { CHAT_SYSTEM_PROMPT, CODE_PROMPT, DETAIL_PROMPT, PROMPT_HEAD, TRANSLATION_PROMPT } = P
59
- console.log(`提示词来源:${loader}\n`)
60
-
61
- let failed = 0
62
- let passed = 0
63
- const assert = (label, ok, extra) => {
64
- console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
65
- if (ok) passed += 1
66
- else failed += 1
67
- }
68
- const has = (text, ...needles) => needles.every((n) => text.includes(n))
69
-
70
- // ───────────────────────── 公共前言 ─────────────────────────
71
- assert('人设:跨领域解读专家(含领域判断要求)', has(PROMPT_HEAD, '跨领域', '先判断【选中文字】实际属于哪个领域'))
72
- assert('人设:领域判断是内部动作,不许写出来', has(PROMPT_HEAD, '不要把判断过程写出来'))
73
- assert('人设:说明用户消息的形态(会话背景 + 选中文字)', has(PROMPT_HEAD, '【会话背景】', '【选中文字】'))
74
-
75
- // ───────────────────────── 翻译 / 解读(首轮)─────────────────────────
76
- assert('翻译提示词拼进了公共前言', TRANSLATION_PROMPT.startsWith(PROMPT_HEAD))
77
- assert(
78
- '翻译:只输出一个章节(不许前言结语)',
79
- has(TRANSLATION_PROMPT, '本轮只输出一个章节', '不要输出其它章节', '不要前言与结语'),
80
- )
81
- assert(
82
- '结构契约:含英文 → 标题 `## 翻译`',
83
- has(TRANSLATION_PROMPT, '## 翻译'),
84
- '客户端按此标题切分渲染,改标题会同时破坏渲染',
85
- )
86
- assert(
87
- '结构契约:全中文 → 标题 `## 解读`',
88
- has(TRANSLATION_PROMPT, '## 解读'),
89
- )
90
- assert(
91
- '结论条:固定写成引用行「在本句中:」',
92
- has(TRANSLATION_PROMPT, '> 在本句中:'),
93
- '面板据此渲染成高亮结论条,格式不能丢',
94
- )
95
- assert(
96
- '音标规则:单词要标 IPA,且拿不准宁可不标',
97
- has(TRANSLATION_PROMPT, '音标', 'IPA') && /拿不准就不写|宁可不标/.test(TRANSLATION_PROMPT),
98
- )
99
- assert(
100
- '词义规则:词性与义项缩进两个空格作为子项',
101
- has(TRANSLATION_PROMPT, '缩进两个空格'),
102
- )
103
- assert(
104
- '语言自适应:按"有没有需要翻译的英文"分情形,只出一种',
105
- has(TRANSLATION_PROMPT, '有没有需要翻译的英文') && has(TRANSLATION_PROMPT, '情形一', '情形二'),
106
- )
107
-
108
- // ───────────────────────── 代码注释(首轮)─────────────────────────
109
- assert('代码提示词拼进了公共前言', CODE_PROMPT.startsWith(PROMPT_HEAD))
110
- assert('结构契约:代码模式标题 `## 注释`', has(CODE_PROMPT, '## 注释'))
111
- assert(
112
- '代码模式:原代码逐字不变、注释插在语句前',
113
- has(CODE_PROMPT, '原代码') && /逐字不变|原样/.test(CODE_PROMPT),
114
- )
115
-
116
- // ───────────────────────── 详解(第二阶段)─────────────────────────
117
- assert('详解提示词拼进了公共前言', DETAIL_PROMPT.startsWith(PROMPT_HEAD))
118
- assert(
119
- '详解:只输出 `## 详解` 一节',
120
- has(DETAIL_PROMPT, '## 详解', '不要输出「## 翻译」或其它章节'),
121
- )
122
- assert(
123
- '详解:要求先判断这段文字的来源(真实内容 vs 示例/测试/引用)',
124
- has(DETAIL_PROMPT, '真实对话/正文', '示例', '测试用例', '演示输出', '引用别人的话'),
125
- '早期缺这条时,示例句会被当成真实陈述解释',
126
- )
127
- assert('详解:单词仍带音标', has(DETAIL_PROMPT, '音标', 'IPA'))
128
-
129
- // ───────────────────────── 追问(小窗对话)─────────────────────────
130
- assert(
131
- '追问:不要求两节结构,问什么答什么',
132
- has(CHAT_SYSTEM_PROMPT, '用户问什么就答什么', '不必把话题拉回选中文字'),
133
- )
134
- assert(
135
- '追问:不许重复前情已给的翻译/详解',
136
- has(CHAT_SYSTEM_PROMPT, '不用重复', '一句话带过'),
137
- )
138
- assert(
139
- '追问:网页模式要给完整单文件 HTML(内联样式与脚本)',
140
- has(CHAT_SYSTEM_PROMPT, '完整的单文件 HTML', '```html', '样式和脚本都内联'),
141
- '小窗会当场渲染成可交互页面,只给片段就渲染不出来',
142
- )
143
- assert(
144
- '追问:【引用 N】块要当成提问材料(不许认成上一轮回答)',
145
- has(CHAT_SYSTEM_PROMPT, '【引用 N】', '当成提问的一部分来答'),
146
- '小窗的「❝ 引用」把引用拼进用户消息,模型得知道那不是它自己的前情',
147
- )
148
- assert(
149
- '追问:【引用处上下文】要被当成判断依据(引用部分用【】标出)',
150
- has(CHAT_SYSTEM_PROMPT, '【引用处上下文】', '被引用的部分用【】标出'),
151
- '主界面/网页的引用自带"当时所在的那一组对话 ± 一组",模型要知道那是上下文而不是正文',
152
- )
153
- assert(
154
- '追问:只写出处的引用要在本轮上下文里找得到它',
155
- has(CHAT_SYSTEM_PROMPT, '小窗第 2 轮回答', '本来就在本轮上下文里'),
156
- '小窗自己的对话不重复进引用块,靠出处标签指路',
157
- )
158
-
159
- // ───────────────────────── 卫生检查(防低级事故)─────────────────────────
160
- const ALL = [PROMPT_HEAD, TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].join('\n')
161
- assert(
162
- '没有未替换的占位符({{…}} / ${…} / TODO)',
163
- !/\{\{[^}]*\}\}|\$\{[^}]*\}|TODO/.test(ALL),
164
- )
165
- assert(
166
- '没有残留的调试口吻(console.log / debugger)',
167
- !/console\.log|debugger/.test(ALL),
168
- )
169
- assert(
170
- '提示词不是空串(防止整体被清空)',
171
- ALL.length > 2000,
172
- `总长 ${ALL.length}`,
173
- )
174
-
175
- // ───────────────────────── 防复活:已弃用的规则不得重新接回 ─────────────────────────
176
- // TWO_LAYER_RULE(「专业 + 说人话、别套模板」的写作规则)**已弃用**:提示词瘦身时
177
- // 把"别写成同一个模子"那五条写法砍掉了,只留两条硬约束(只出 `## 详解` 一节 + 音标)。
178
- // 它曾作为常量残留(只定义、不使用),已于 2026-09-24 删除。
179
- // 这条断言防止有人把那段文字重新塞回提示词里 —— 那是被明确废弃的设计。
180
- {
181
- const resurrected = [TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].some((p) =>
182
- p.includes('别写成同一个模子') || p.includes('**写法:内容有要求,形式随内容走。**'),
183
- )
184
- assert(
185
- '已弃用的「两层写法规则」没有被重新塞回提示词',
186
- !resurrected,
187
- resurrected ? '检测到已废弃规则的原文,请确认是否有意恢复该设计' : '符合当前设计(该规则已弃用并删除)',
188
- )
189
- }
190
-
191
- console.log(`\n=== 提示词契约测试结束:${passed} 通过 / ${failed} 失败 ===`)
192
- if (failed > 0) process.exitCode = 1
193
- process.exit(process.exitCode ?? 0)