@yfwu2020/dsh-selection-explain 0.1.3 → 0.4.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.
@@ -41,6 +41,23 @@ class FakeEl {
41
41
  this.scrollHeight = 0
42
42
  this.clientHeight = 0
43
43
  this._listeners = new Map()
44
+ // 输入框语义:textarea/input 的 value 与选区。真实 DOM 里给 value 赋值会把光标
45
+ // 挪到末尾、setSelectionRange 能定位 —— 语音输入要验"插在光标处",桩必须跟着做到。
46
+ this._value = ''
47
+ this.selectionStart = 0
48
+ this.selectionEnd = 0
49
+ }
50
+ get value() {
51
+ return this._value
52
+ }
53
+ set value(next) {
54
+ this._value = next === undefined || next === null ? '' : String(next)
55
+ this.selectionStart = this._value.length
56
+ this.selectionEnd = this._value.length
57
+ }
58
+ setSelectionRange(start, end) {
59
+ this.selectionStart = start
60
+ this.selectionEnd = end === undefined ? start : end
44
61
  }
45
62
  appendChild(child) {
46
63
  if (child.parentNode) child.parentNode.removeChild(child)
@@ -89,6 +106,10 @@ class FakeEl {
89
106
  getClientRects() {
90
107
  return [{ left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }]
91
108
  }
109
+ get firstChild() {
110
+ // 真实 DOM 的 firstChild:插件用 while (node.firstChild) 清空子节点,桩必须有
111
+ return this.children.length ? this.children[0] : null
112
+ }
92
113
  get textContent() {
93
114
  return textOf(this)
94
115
  }
@@ -226,9 +247,48 @@ const historyStore = new Map()
226
247
  const historySaved = []
227
248
  let historyList = []
228
249
  const openedSessions = []
250
+ /** 引用上下文路由的调用记录(验"什么时候去问 host、带了什么")。 */
251
+ const quoteContextCalls = []
229
252
  /** 散文样本(固定 fixture,避免依赖真实网络/缓存命中)。 */
230
253
  const PROSE_PROBE = 'the migration ran long, so we ship Wednesday'
231
254
 
255
+ /**
256
+ * 语音输入(麦克风)这一段的假 host:目录 / 转写 / 准备。
257
+ * 真链路由 scripts/test-speech.mjs(WAV 校验 + 真路由)与真浏览器冒烟覆盖;
258
+ * 这里只验客户端编排(状态机 / 提示语 / 插字位置 / 该松麦克风时松掉)。
259
+ */
260
+ let speechCatalog = {
261
+ ok: true,
262
+ available: true,
263
+ reason: '',
264
+ error: '',
265
+ providers: [
266
+ {
267
+ id: 'sensevoice-local',
268
+ name: 'SenseVoiceSmall (INT8)',
269
+ location: 'host-local',
270
+ languages: ['auto', 'zh', 'en'],
271
+ downloadSources: ['https://huggingface.co'],
272
+ phase: 'standby',
273
+ step: '',
274
+ completedBytes: 0,
275
+ totalBytes: 0,
276
+ message: '',
277
+ },
278
+ ],
279
+ selection: { providerId: 'sensevoice-local', language: 'auto' },
280
+ limits: { maxSeconds: 60, maxBytes: 4194304 },
281
+ }
282
+ /** 下一次转写的返回(测试里逐条改成失败 / 空 / 正常)。 */
283
+ let speechTranscript = { ok: true, text: '这段是语音转出来的问题', providerId: 'sensevoice-local', seconds: 2.4 }
284
+ /** 按顺序取用的返回(实时字幕一次录音要发好几个请求:预览 / 定稿 / 最终整段)。 */
285
+ let speechTranscriptQueue = []
286
+ /** 粘住的返回:一直返回同一个(模拟"人还在说,每拍预览都该是这句")。 */
287
+ let speechTranscriptRepeat = null
288
+ let speechCatalogCalls = 0
289
+ let speechPrepareCalls = 0
290
+ const speechTranscribeCalls = []
291
+
232
292
  /** Python 代码选区用例(验证语言标记影响高亮规则)。 */
233
293
  const PY_SNIPPET = ['def add(a, b):', ' # 这里是可以相加的数字', ' return a + b'].join('\n')
234
294
 
@@ -328,6 +388,13 @@ function withAbort(response, signal) {
328
388
 
329
389
  const routeFetch = (input, init) => {
330
390
  const url = typeof input === 'string' ? input : ''
391
+ // blob:(划词桥的交互式预览用例):从内存表里读回我们自己建的那份文档。
392
+ // content-type 跟着 blob 的 type 走(真实浏览器就是这么给的)——插件要靠它搬 charset。
393
+ if (url.startsWith('blob:')) {
394
+ const blob = blobStore.get(url)
395
+ if (!blob) return Promise.reject(new Error('unknown blob: ' + url))
396
+ return Promise.resolve(new Response(blob, { headers: { 'content-type': blob.type || 'text/html' } }))
397
+ }
331
398
  // 模型清单:用 fixture(真实 host 有 41 个模型,跑用例时不该依赖它)
332
399
  if (url.indexOf('/selection-explain/api/models') >= 0) {
333
400
  const body = globalThis.__modelCatalogFixture || { ok: true, current: null, stages: null, models: [] }
@@ -376,6 +443,62 @@ const routeFetch = (input, init) => {
376
443
  }),
377
444
  )
378
445
  }
446
+ // 引用上下文:host 侧"引用所在那一组对话 ± 一组"的那份(fixture —— 取轮算法本身由
447
+ // test-transcript.mjs 覆盖;这里验的是**客户端怎么用它**)。globalThis.__quoteContextFixture
448
+ // 可切成 'miss'(定位不到)/ 'error'(路由失败),用来验兜底路径。
449
+ if (url.indexOf('/selection-explain/api/quote-context') >= 0) {
450
+ let payload = {}
451
+ try {
452
+ payload = JSON.parse(String(init && init.body))
453
+ } catch (error) {
454
+ payload = { parse_error: String(error) }
455
+ }
456
+ quoteContextCalls.push(payload)
457
+ const fixture = globalThis.__quoteContextFixture
458
+ if (fixture === 'error') {
459
+ return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'boom' }), { status: 500, headers: { 'content-type': 'application/json' } }))
460
+ }
461
+ if (fixture === 'miss') {
462
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, matched: false, context: '', rounds: 0 }), { headers: { 'content-type': 'application/json' } }))
463
+ }
464
+ const context = [
465
+ '用户:上一句问的是什么?',
466
+ '助手:上一句的回答。',
467
+ `用户:【${payload.text}】`,
468
+ '助手:引用之后的那句回答。',
469
+ '用户:再下一句提问。',
470
+ '助手:再下一句回答。',
471
+ ].join('\n')
472
+ return Promise.resolve(
473
+ new Response(JSON.stringify({ ok: true, matched: true, context, rounds: 3, chars: context.length }), {
474
+ headers: { 'content-type': 'application/json' },
475
+ }),
476
+ )
477
+ }
478
+ // 语音输入:目录 / 转写 / 准备。三条都用 fixture(真识别见 test-speech.mjs 与真浏览器冒烟)。
479
+ if (url.indexOf('/selection-explain/api/speech') >= 0) {
480
+ if (url.indexOf('/transcribe') >= 0) {
481
+ let payload = null
482
+ try {
483
+ payload = JSON.parse(String(init && init.body))
484
+ } catch (error) {
485
+ payload = { parse_error: String(error) }
486
+ }
487
+ speechTranscribeCalls.push(payload)
488
+ const scripted = speechTranscriptRepeat || (speechTranscriptQueue.length ? speechTranscriptQueue.shift() : speechTranscript)
489
+ return Promise.resolve(new Response(JSON.stringify(scripted), { headers: { 'content-type': 'application/json' } }))
490
+ }
491
+ if (url.indexOf('/prepare') >= 0) {
492
+ speechPrepareCalls += 1
493
+ return Promise.resolve(
494
+ new Response(JSON.stringify({ ok: true, providerId: 'sensevoice-local', catalog: speechCatalog }), {
495
+ headers: { 'content-type': 'application/json' },
496
+ }),
497
+ )
498
+ }
499
+ speechCatalogCalls += 1
500
+ return Promise.resolve(new Response(JSON.stringify(speechCatalog), { headers: { 'content-type': 'application/json' } }))
501
+ }
379
502
  let body = null
380
503
  if (init && init.body) {
381
504
  try {
@@ -385,6 +508,15 @@ const routeFetch = (input, init) => {
385
508
  }
386
509
  sent.push(body)
387
510
  }
511
+ // 语音输入那一段的"把面板打开、翻译就绪"夹具(比 stage-probe 多一个 done 阶段)
512
+ if (body && body.text === 'voice-probe') {
513
+ const payload = [
514
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
515
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n语音用例的翻译\n' })}\n\n`,
516
+ `data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`,
517
+ ].join('')
518
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
519
+ }
388
520
  if (body && body.text === 'stage-probe') {
389
521
  const only = body.stage === 'detail' ? '## 详解\nSTAGE-DETAIL' : '## 翻译\nSTAGE-TRANSLATION'
390
522
  const payload = [
@@ -757,15 +889,64 @@ const routeFetch = (input, init) => {
757
889
  ].join('')
758
890
  return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
759
891
  }
892
+ // 引用(❝)用例:首轮固定给一句可被引用的正文,详解在同一个小窗里另走 stage=detail
893
+ if (body && body.text === 'quote-probe') {
894
+ const only = body.stage === 'detail' ? '## 详解\n引用用例的详解\n' : '## 翻译\n引用用例的翻译段落(拿来被引用)\n'
895
+ const payload = [
896
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage === 'detail' ? 'detail' : 'translation' })}\n\n`,
897
+ `data: ${JSON.stringify({ type: 'delta', text: only })}\n\n`,
898
+ `data: ${JSON.stringify({ type: 'done', chars: only.length })}\n\n`,
899
+ ].join('')
900
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
901
+ }
902
+ // 「等待中的那条不给引用整条」专用:首轮立刻出,追问要等 500ms 才吐第一个字
903
+ if (body && body.text === 'quote-wait-probe' && !body.question) {
904
+ const only = '## 翻译\n等待用例的翻译\n'
905
+ const payload = [
906
+ `data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', stage: 'translation' })}\n\n`,
907
+ `data: ${JSON.stringify({ type: 'delta', text: only })}\n\n`,
908
+ `data: ${JSON.stringify({ type: 'done', chars: only.length })}\n\n`,
909
+ ].join('')
910
+ return Promise.resolve(new Response(payload, { headers: { 'content-type': 'text/event-stream' } }))
911
+ }
912
+ if (body && body.question && body.text === 'quote-wait-probe') {
913
+ const encoder = new TextEncoder()
914
+ const stream = new ReadableStream({
915
+ async start(controller) {
916
+ controller.enqueue(
917
+ encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat', effort: 'low' })}\n\n`),
918
+ )
919
+ await new Promise((r) => setTimeout(r, 500))
920
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'delta', text: '等待用例的回答' })}\n\n`))
921
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', chars: 8 })}\n\n`))
922
+ controller.close()
923
+ },
924
+ })
925
+ return Promise.resolve(new Response(stream, { headers: { 'content-type': 'text/event-stream' } }))
926
+ }
760
927
  return fetch(typeof input === 'string' && input.startsWith('/') ? ORIGIN + input : input, init)
761
928
  }
762
-
763
929
  const browserFetch = (input, init) => {
764
930
  const out = routeFetch(input, init)
765
931
  const signal = init && init.signal
766
932
  if (!signal || !out || typeof out.then !== 'function') return out
767
933
  return out.then((response) => (response && response.body ? withAbort(response, signal) : response))
768
934
  }
935
+ /**
936
+ * 划词桥的 blob 桩:宿主交互式预览的外层文档就是 `blob:`(见 ui-sidebar-documentpreview
937
+ * 的 HtmlFrame)——桥要 fetch 它、再把注入过脚本的那份重新发成一个 blob 换上去。
938
+ */
939
+ const blobStore = new Map()
940
+ let blobSeq = 0
941
+ URL.createObjectURL = (blob) => {
942
+ blobSeq += 1
943
+ const url = `blob:stub/${blobSeq}`
944
+ blobStore.set(url, blob)
945
+ return url
946
+ }
947
+ URL.revokeObjectURL = (url) => {
948
+ blobStore.delete(url)
949
+ }
769
950
  new Function('window', 'document', 'location', 'navigator', 'requestAnimationFrame', 'fetch', source)(
770
951
  windowStub,
771
952
  documentStub,
@@ -884,6 +1065,17 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
884
1065
 
885
1066
  }
886
1067
 
1068
+ // 引用(❝)的**离线**契约:浮标进了浮层、CSS 规则齐全(真实交互在宿主可达时另有一段)
1069
+ {
1070
+ const css = readFileSync(BUNDLE, 'utf8')
1071
+ const quoteBtn = Array.from(walk(mount)).find((n) => String(n.className).indexOf('dsh-sel-quotebtn') >= 0)
1072
+ assert('浮层里有「❝ 引用」浮标,且默认藏着', !!quoteBtn && quoteBtn.style.display === 'none' && textOf(quoteBtn).indexOf('引用') >= 0, quoteBtn ? textOf(quoteBtn) : '未找到')
1073
+ assert('CSS 契约:引用区默认收起、data-show 控制显隐', /\.dsh-sel-quotes\{display:none;flex-direction:column/.test(css) && /\.dsh-sel-quotes\[data-show="1"\]\{display:flex\}/.test(css), '')
1074
+ assert('CSS 契约:引用卡片三段(来源 / 摘要 / 删除)', /\.dsh-sel-quotechip-src\{/.test(css) && /\.dsh-sel-quotechip-text\{/.test(css) && /\.dsh-sel-quotechip-x\{/.test(css), '')
1075
+ assert('CSS 契约:气泡里的引用块单行省略(长引用不撑破气泡)', /\.dsh-sel-bqitem\{[\s\S]{0,420}?text-overflow:ellipsis;white-space:nowrap\}/.test(css), '')
1076
+ assert('CSS 契约:「引用整条」平时隐身,鼠标移到这条消息上才出现', /\.dsh-sel-bubquote\{[\s\S]{0,520}?opacity:0;transition:opacity/.test(css) && /\.dsh-sel-bubble:hover \.dsh-sel-bubquote,\.dsh-sel-bubquote:focus-visible\{opacity:\.66\}/.test(css), '')
1077
+ }
1078
+
887
1079
 
888
1080
  // ───────────────────────── 点击 → 弹窗 + 真实 host 流 ─────────────────────────
889
1081
  button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
@@ -967,6 +1159,17 @@ if (HOST_UP) {
967
1159
  'src/index.ts',
968
1160
  )
969
1161
  assert('客户端给了新搜索工具中文标签', /\(name === 'advanced_search'\) return '联网搜索'/.test(readFileSync(resolve(HERE, '..', 'src', 'client', 'index.js'), 'utf8')), '')
1162
+ {
1163
+ // 源码契约(桩 DOM 里没有真实焦点,只能用源码钉住):
1164
+ // 开窗那一下的自动聚焦会把选区清掉 —— 小窗里正划着词(准备点引用)时必须让位,
1165
+ // 否则就是"划好了词,一松手浮标没出现"(实测:开窗后首轮刚出完那一帧正好撞上)。
1166
+ const clientSrc = readFileSync(resolve(HERE, '..', 'src', 'client', 'index.js'), 'utf8')
1167
+ assert(
1168
+ '开窗自动聚焦会让位给"小窗里正在划的词"',
1169
+ /function hasPanelSelection\(\)/.test(clientSrc) && /if \(hasPanelSelection\(\)\) \{/.test(clientSrc),
1170
+ 'src/client/index.js',
1171
+ )
1172
+ }
970
1173
  assert(
971
1174
  '追问档位默认 high(翻译仍 low、详解仍 high)',
972
1175
  /chatReasoningEffort: z\.string\(\)\.default\('high'\)/.test(hostSrc) &&
@@ -2503,6 +2706,1363 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
2503
2706
  assert('删除键不会误触发回放(回放会重新写历史,这里应为 0 次)', replayHits.length === 0, String(replayHits.length))
2504
2707
  }
2505
2708
 
2709
+ // ───────────────────────── 侧边栏网页(iframe)划词桥 ─────────────────────────
2710
+ // 侧边栏 HTML 预览是不透明源沙箱 iframe:父页面读不到里面的选区,
2711
+ // 所以桥由帧内主动上报。这一段验的是**父侧接线**:注入、来源校验、坐标换算、
2712
+ // 上下文与标签走通、帧内清空选区后收浮标。
2713
+ let bridgeOwnedBlob = ''
2714
+ {
2715
+ // 上一段(历史列表)是开着面板测的;划词桥按老规矩"面板开着时不另开一个",
2716
+ // 先收起来,否则下面每条消息都会被 panelOpen 挡掉(那就测了个寂寞)。
2717
+ hook.close()
2718
+ const beforeBridge = hook.selection()
2719
+
2720
+ /** 宿主基础预览(BasicHtmlFrame):DOMPurify 清洗 + script-src 'none',srcdoc + sandbox=""。 */
2721
+ const SANITIZED =
2722
+ '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
2723
+ "script-src 'none'; style-src 'unsafe-inline'\"><style>body{color:red}</style></head>" +
2724
+ '<body><p>the migration ran long, so we ship Wednesday</p></body></html>'
2725
+ const frame = new FakeEl('iframe')
2726
+ frame.setAttribute('data-html-preview', 'true')
2727
+ frame.setAttribute('sandbox', '')
2728
+ frame.setAttribute('srcdoc', SANITIZED)
2729
+ frame.isConnected = true
2730
+ const frameWindow = { name: 'sidebar-frame' }
2731
+ frame.contentWindow = frameWindow
2732
+
2733
+ documentStub.querySelectorAll = (selector) => (selector === 'iframe' ? [frame] : [])
2734
+ const scanned = hook.bridgeScan()
2735
+ assert('扫到侧边栏预览帧并桥上', scanned.frames.length === 1 && scanned.frames[0].srcdoc === true, JSON.stringify(scanned.frames))
2736
+
2737
+ const injected = frame.getAttribute('srcdoc')
2738
+ assert('桥脚本注入了 srcdoc(内容没被替换掉)', injected.indexOf('data-dsh-sel-bridge') > 0 && injected.indexOf('the migration ran long, so we ship Wednesday') > 0)
2739
+ assert('基础预览的 CSP 只放宽 script-src', /script-src 'unsafe-inline'/.test(injected) && /default-src 'none'/.test(injected))
2740
+ assert('沙箱补 allow-scripts,但不给 allow-same-origin', frame.getAttribute('sandbox') === 'allow-scripts', String(frame.getAttribute('sandbox')))
2741
+
2742
+ // 陌生窗口发来的同款消息一律不理(消息可以被任意页面伪造,来源必须对得上)
2743
+ windowStub.dispatch('message', { source: { name: 'attacker' }, data: { __dshSel: 1, kind: 'selection', sel: { text: '伪造', context: '', rect: { x: 0, y: 0, right: 10, bottom: 10, w: 10, h: 10 } } } })
2744
+ const afterForged = hook.selection()
2745
+ assert(
2746
+ '陌生来源的消息被忽略(选区没被改写)',
2747
+ (beforeBridge === null && afterForged === null) || (!!afterForged && afterForged.text === beforeBridge.text && afterForged.source === beforeBridge.source),
2748
+ JSON.stringify(afterForged),
2749
+ )
2750
+ assert('陌生来源的消息不会点亮 bridge()', hook.bridge().active === false)
2751
+
2752
+ // 帧内报来一条选区:帧内视口坐标 + iframe 自身的 getBoundingClientRect(桩:left 40 / top 100)
2753
+ windowStub.dispatch('message', {
2754
+ source: frameWindow,
2755
+ data: {
2756
+ __dshSel: 1,
2757
+ kind: 'selection',
2758
+ sel: {
2759
+ text: 'the migration ran long',
2760
+ context: 'PM: 这周能发布吗? Dev: 【the migration ran long】, so we ship Wednesday.',
2761
+ keyContext: 'PM: 这周能发布吗? Dev:',
2762
+ label: '',
2763
+ rect: { x: 10, y: 20, right: 120, bottom: 38, w: 110, h: 18 },
2764
+ },
2765
+ },
2766
+ })
2767
+ const bridgeSelection = hook.selection()
2768
+ assert('帧内选区让浮标出现在「帧偏移 + 帧内坐标」处', button.style.display === 'inline-flex' && button.style.left === '98px' && button.style.top === '85px', button.style.left + '/' + button.style.top)
2769
+ assert('选区记成 iframe 来源', !!bridgeSelection && bridgeSelection.source === 'iframe' && bridgeSelection.text === 'the migration ran long', JSON.stringify(bridgeSelection))
2770
+ assert('标签按帧来源给(侧边栏网页)', bridgeSelection.label === '侧边栏网页', bridgeSelection.label)
2771
+ assert('上下文由帧内带来(含【】标记)', bridgeSelection.context.indexOf('【the migration ran long】') > 0, bridgeSelection.context)
2772
+ assert('bridge() 报 active', hook.bridge().active === true)
2773
+
2774
+ // 交互式预览:外层是宿主 bootstrap(blob: + document.write),桥要换一份带脚本的 blob 上去
2775
+ const BOOTSTRAP =
2776
+ '<!doctype html><meta charset="utf-8"><script>(()=>{document.open();document.write("<p>the migration ran long, so we ship Wednesday</p>");document.close()})()</scr' + 'ipt>'
2777
+ const originalBlob = URL.createObjectURL(new Blob([BOOTSTRAP], { type: 'text/html;charset=utf-8' }))
2778
+ const blobFrame = new FakeEl('iframe')
2779
+ blobFrame.setAttribute('data-html-preview', 'true')
2780
+ blobFrame.setAttribute('sandbox', 'allow-scripts')
2781
+ blobFrame.setAttribute('src', originalBlob)
2782
+ blobFrame.isConnected = true
2783
+ const blobWindow = { name: 'sidebar-blob-frame' }
2784
+ blobFrame.contentWindow = blobWindow
2785
+ documentStub.querySelectorAll = (selector) => (selector === 'iframe' ? [frame, blobFrame] : [])
2786
+ hook.bridgeScan()
2787
+ await sleep(20) // fetch(blob) → 注入 → 换 src 是异步的
2788
+ bridgeOwnedBlob = blobFrame.getAttribute('src')
2789
+ assert('交互式预览:src 换成我们重发的那份 blob', bridgeOwnedBlob !== originalBlob && bridgeOwnedBlob.startsWith('blob:'), bridgeOwnedBlob)
2790
+ assert('交互式预览:换上去的文档里有桥脚本', String(await (await browserFetch(bridgeOwnedBlob)).text()).indexOf('data-dsh-sel-bridge') > 0)
2791
+ assert(
2792
+ '交互式预览:重发时把原 blob 的 content-type 搬过来(charset 不能丢,否则整页乱码)',
2793
+ (blobStore.get(bridgeOwnedBlob) || {}).type === 'text/html;charset=utf-8',
2794
+ String((blobStore.get(bridgeOwnedBlob) || {}).type),
2795
+ )
2796
+ assert('交互式预览:宿主原来那份 blob 不归我们动(它自己回收)', blobStore.has(originalBlob))
2797
+ assert('交互式预览:我们自己那份 blob 登记在册(清理时回收)', hook.bridge().frames.some((item) => item.own === true))
2798
+
2799
+ // 帧内清空选区 → 收浮标(顶层选区不会塌,所以只能靠这条消息)
2800
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'clear' } })
2801
+ assert('帧内清空选区后收浮标', button.style.display === 'none' && hook.bridge().active === false)
2802
+
2803
+ // ── 消失时机:帧内的"按下"也要收浮标(父页面收不到帧里的 mousedown)──
2804
+ windowStub.dispatch('message', {
2805
+ source: frameWindow,
2806
+ data: {
2807
+ __dshSel: 1,
2808
+ kind: 'selection',
2809
+ sel: { text: '按住前那一段', context: '【按住前那一段】', keyContext: '', label: '', rect: { x: 4, y: 6, right: 40, bottom: 24, w: 36, h: 18 } },
2810
+ },
2811
+ })
2812
+ assert('(准备)选区先让浮标亮着', button.style.display === 'inline-flex')
2813
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'press' } })
2814
+ assert('帧内按下:浮标立刻收起', button.style.display === 'none')
2815
+ assert('帧内按下:这条选区本身还留着(松手后可能还要用)', hook.selection() !== null && hook.selection().source === 'iframe', JSON.stringify(hook.selection()))
2816
+
2817
+ // 按下的若是**另一个**网页:那条选区已经不是用户在看的东西了,状态一起清
2818
+ windowStub.dispatch('message', {
2819
+ source: frameWindow,
2820
+ data: {
2821
+ __dshSel: 1,
2822
+ kind: 'selection',
2823
+ sel: { text: '另一个帧的词', context: '【另一个帧的词】', keyContext: '', label: '', rect: { x: 4, y: 6, right: 40, bottom: 24, w: 36, h: 18 } },
2824
+ },
2825
+ })
2826
+ windowStub.dispatch('message', { source: blobWindow, data: { __dshSel: 1, kind: 'press' } })
2827
+ assert('在另一个网页里按下:浮标收起且旧选区状态清掉', button.style.display === 'none' && hook.selection() === null && hook.bridge().active === false)
2828
+
2829
+ // 交互式预览那一帧报来的选区 → 点浮标 → 面板用的就是桥给的上下文
2830
+ windowStub.dispatch('message', {
2831
+ source: blobWindow,
2832
+ data: {
2833
+ __dshSel: 1,
2834
+ kind: 'selection',
2835
+ sel: {
2836
+ text: 'slipped',
2837
+ context: 'the deadline 【slipped】 to Wednesday',
2838
+ keyContext: 'the deadline',
2839
+ label: '代码块',
2840
+ rect: { x: 8, y: 12, right: 60, bottom: 30, w: 52, h: 18 },
2841
+ },
2842
+ },
2843
+ })
2844
+ assert('第二帧的选区也能认(多标签页)', button.style.display === 'inline-flex' && hook.selection().text === 'slipped', JSON.stringify(hook.selection()))
2845
+ assert('帧内给的标签优先(代码块)', hook.selection().label === '代码块', hook.selection().label)
2846
+ button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2847
+ const bridgePayload = hook.payload()
2848
+ assert('点浮标后面板用桥给的上下文(不读顶层 DOM)', !!bridgePayload && bridgePayload.context === 'the deadline 【slipped】 to Wednesday', bridgePayload && bridgePayload.context)
2849
+ assert('面板标签也是桥给的', bridgePayload.label === '代码块', bridgePayload && bridgePayload.label)
2850
+ hook.close()
2851
+
2852
+ // ── 别的插件在侧边栏渲染的生成网页(「图解」那种:blob: + allow-scripts,没有宿主标记)──
2853
+ const visualFrame = new FakeEl('iframe')
2854
+ visualFrame.setAttribute('class', 'dsv-frame')
2855
+ visualFrame.setAttribute('title', '图解')
2856
+ visualFrame.setAttribute('sandbox', 'allow-scripts allow-forms allow-modals allow-popups')
2857
+ visualFrame.setAttribute('src', URL.createObjectURL(new Blob(['<!doctype html><html><head><meta charset="utf-8"></head><body><p>图解页</p></body></html>'], { type: 'text/html' })))
2858
+ visualFrame.isConnected = true
2859
+ const visualWindow = { name: 'reply-visual-frame' }
2860
+ visualFrame.contentWindow = visualWindow
2861
+
2862
+ // 反面用例:不该被动的帧
2863
+ const opaqueFrame = new FakeEl('iframe') // 没有 allow-scripts 的第三方帧:不给它加权限
2864
+ opaqueFrame.setAttribute('sandbox', '')
2865
+ opaqueFrame.setAttribute('srcdoc', '<!doctype html><p>别人的沙箱</p>')
2866
+ opaqueFrame.isConnected = true
2867
+ const remoteFrame = new FakeEl('iframe') // 远端站点:没有任何注入手段
2868
+ remoteFrame.setAttribute('sandbox', 'allow-scripts')
2869
+ remoteFrame.setAttribute('src', 'https://example.com/')
2870
+ remoteFrame.isConnected = true
2871
+ const ownFrame = new FakeEl('iframe') // 我们自己小窗里的网页答案预览
2872
+ ownFrame.setAttribute('data-preview-id', 'pv1')
2873
+ ownFrame.setAttribute('sandbox', 'allow-scripts')
2874
+ ownFrame.setAttribute('srcdoc', '<!doctype html><p>生成的网页</p>')
2875
+ ownFrame.isConnected = true
2876
+ panel.appendChild(ownFrame)
2877
+
2878
+ documentStub.querySelectorAll = (selector) =>
2879
+ selector === 'iframe' ? [frame, blobFrame, visualFrame, opaqueFrame, remoteFrame, ownFrame] : []
2880
+ hook.bridgeScan()
2881
+ await sleep(20) // visualFrame 的 blob 分支也是异步的
2882
+ const bridgedNow = hook.bridge().frames
2883
+ assert(
2884
+ '「图解」那种 blob 帧也被桥上(不只认宿主标记)',
2885
+ bridgedNow.length === 3 && visualFrame.getAttribute('data-dsh-sel-bridged') === '1',
2886
+ JSON.stringify(bridgedNow),
2887
+ )
2888
+ assert('第三方没有 allow-scripts 的沙箱帧不动它(不给别人加权限)', opaqueFrame.getAttribute('data-dsh-sel-bridged') === null && opaqueFrame.getAttribute('sandbox') === '')
2889
+ assert('远端站点 iframe 不动它', remoteFrame.getAttribute('data-dsh-sel-bridged') === null)
2890
+ assert('我们小窗里的网页答案预览不桥(面板开着也不会用它)', ownFrame.getAttribute('data-dsh-sel-bridged') === null)
2891
+ assert('「图解」帧的 src 换成了我们重发的那份', visualFrame.getAttribute('src').indexOf('blob:') === 0 && visualFrame.getAttribute('src') !== null)
2892
+
2893
+ windowStub.dispatch('message', {
2894
+ source: visualWindow,
2895
+ data: {
2896
+ __dshSel: 1,
2897
+ kind: 'selection',
2898
+ sel: { text: '图解里的词', context: '【图解里的词】的上下文', keyContext: '', label: '', rect: { x: 5, y: 6, right: 40, bottom: 22, w: 35, h: 16 } },
2899
+ },
2900
+ })
2901
+ const visualSelection = hook.selection()
2902
+ assert('「图解」帧的选区也认', !!visualSelection && visualSelection.text === '图解里的词', JSON.stringify(visualSelection))
2903
+ assert('位置标签取帧自己的 title(图解)', visualSelection.label === '图解', visualSelection.label)
2904
+ hook.close()
2905
+ }
2906
+
2907
+ // ───────────────────────── 引用(❝):小窗里的内容 / 主界面选中的文字 → 输入框 ─────────────────────────
2908
+ // 两个入口共用一份清单(state.quotes),发送时拼进这一条消息:
2909
+ // ① 小窗开着时划小窗里的正文 → 「❝ 引用」;
2910
+ // ② 小窗开着时划主界面(含侧边栏网页)上的文字 → 同一个浮标;
2911
+ // ③ 助手气泡末尾的「引用整条」。
2912
+ {
2913
+ const quoteRect = { left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }
2914
+ /** 造一条"本文档里的选区":起点落在指定的节点上(祖先链就是插件要判定的东西)。 */
2915
+ const selectText = (node, text) => {
2916
+ const range = {
2917
+ startContainer: node,
2918
+ endContainer: node,
2919
+ cloneRange: () => range,
2920
+ getClientRects: () => [quoteRect],
2921
+ getBoundingClientRect: () => quoteRect,
2922
+ }
2923
+ windowStub.getSelection = () => ({
2924
+ isCollapsed: false,
2925
+ rangeCount: 1,
2926
+ toString: () => text,
2927
+ getRangeAt: () => range,
2928
+ })
2929
+ }
2930
+ const restoreGetSelection = windowStub.getSelection
2931
+ const quoteButton = hook.quoteNode()
2932
+ const quotesBox = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-quotes')
2933
+ assert('输入框上方有引用区(默认收起)', !!quotesBox && quotesBox.getAttribute('data-show') === '0', quotesBox ? quotesBox.getAttribute('data-show') : '未找到')
2934
+
2935
+ // 本段自带前提:重开一个小窗(独一无二的选中文字,不吃缓存/历史)
2936
+ hook.close()
2937
+ hook.open('quote-probe', '引用用例的上下文片段', '引用测试')
2938
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
2939
+ assert('(前提)引用用例的首轮出完', hook.state().phase === 'done', hook.state().phase)
2940
+
2941
+ // ① 小窗里选中的正文(翻译节)
2942
+ // ⚠️ mouseup 的 target 必须是**面板里的节点**(真实浏览器就是这样上报的):
2943
+ // 早先 mouseup 对面板内的目标直接 return,小窗里划词毫无反应 —— 用 document.body
2944
+ // 当 target 的写法测不出来(实测踩过:只有「引用整条」能用)。
2945
+ const sectionP = Array.from(walk(panel)).find((n) => n.tagName === 'P')
2946
+ selectText(sectionP, '引用用例的翻译段落')
2947
+ documentStub.dispatch('mouseup', { target: sectionP })
2948
+ await sleep(30)
2949
+ const inPanel = hook.quoteState()
2950
+ assert('小窗里划词:浮出「❝ 引用」', inPanel.visible === true, JSON.stringify(inPanel))
2951
+ assert(
2952
+ '来源写到"哪一节"(用该节自己的标题)',
2953
+ !!inPanel.selection && inPanel.selection.label === '小窗「翻译」节' && inPanel.selection.source === 'panel',
2954
+ JSON.stringify(inPanel.selection),
2955
+ )
2956
+ assert('小窗开着时不弹「✦ 解读」(要解读直接在输入框里问)', button.style.display === 'none', String(button.style.display))
2957
+
2958
+ quoteButton.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2959
+ await sleep(10)
2960
+ let quotes = hook.quotes()
2961
+ assert('点一下 → 引用区多了一张卡片', quotes.length === 1 && quotes[0].text === '引用用例的翻译段落' && quotes[0].label === '小窗「翻译」节', JSON.stringify(quotes))
2962
+ assert('卡片画进输入框上方(来源 + 摘要)', quotesBox.getAttribute('data-show') === '1' && textOf(quotesBox).indexOf('小窗「翻译」节') >= 0 && textOf(quotesBox).indexOf('引用用例的翻译段落') >= 0, textOf(quotesBox))
2963
+ assert('加完引用浮标自己收起来("已经进去了"的信号)', quoteButton.style.display === 'none', String(quoteButton.style.display))
2964
+ assert('只挂引用、没写问题时发送键可用', askSend.disabled === false, 'disabled=' + askSend.disabled)
2965
+ assert(
2966
+ '小窗里的引用**不带上下文**(小窗这几轮对话本来就会随历史进模型)',
2967
+ quotes[0].context === '' && quotes[0].session === false,
2968
+ JSON.stringify({ context: quotes[0].context, session: quotes[0].session }),
2969
+ )
2970
+ assert(
2971
+ '引用浮标画在面板**之上**(小窗里划词才看得见、点得着)',
2972
+ Number(quoteButton.style.zIndex) > Number(panel.style.zIndex),
2973
+ `quote=${quoteButton.style.zIndex} panel=${panel.style.zIndex}`,
2974
+ )
2975
+
2976
+ // 划一大段(超过单段引用上限)也必须有反应:浮标照出,加进去时截断并说明
2977
+ {
2978
+ selectText(sectionP, '长'.repeat(4200))
2979
+ documentStub.dispatch('mouseup', { target: sectionP })
2980
+ await sleep(30)
2981
+ assert('超长选区照样浮出引用浮标("划长一点就没反应"是坑)', hook.quoteState().visible === true, JSON.stringify(hook.quoteState()))
2982
+ quoteButton.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2983
+ await sleep(10)
2984
+ const longQuote = hook.quotes().pop()
2985
+ assert(
2986
+ '超长引用按单段上限截断,并在状态里说明',
2987
+ !!longQuote && longQuote.text.length >= 3000 && longQuote.text.length <= 3001 && hook.state().status.indexOf('已截断') > 0,
2988
+ `${longQuote && longQuote.text.length} · ${hook.state().status}`,
2989
+ )
2990
+ const chipList = Array.from(walk(quotesBox)).filter((n) => n.className === 'dsh-sel-quotechip-x')
2991
+ chipList[chipList.length - 1].dispatch('click', { stopPropagation() {} })
2992
+ await sleep(5)
2993
+ assert('删掉超长那条后回到 1 条', hook.quotes().length === 1, JSON.stringify(hook.quotes().map((q) => q.text.length)))
2994
+ // 把浮标状态也归零:不然下面那次"再点一次"的点击会把这段长文又加回来
2995
+ //(浮标虽然收起来了,但 state.quoteSelection 还留着上一次的选区)
2996
+ windowStub.getSelection = () => ({ isCollapsed: true, rangeCount: 0, toString: () => '' })
2997
+ documentStub.dispatch('mouseup', { target: sectionP })
2998
+ await sleep(20)
2999
+ }
3000
+
3001
+ const clicksBefore = hook.quoteState().visible
3002
+ quoteButton.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3003
+ assert('浮标收起后再点它不会重复加', hook.quotes().length === 1 && clicksBefore === false, JSON.stringify(hook.quotes().map((q) => q.text)))
3004
+
3005
+ // 同一段再加一次:不加,但要有交代(点了"没反应"是最难查的观感)
3006
+ assert(
3007
+ '重复的引用不再加,并说明原因',
3008
+ hook.quote('引用用例的翻译段落', '小窗回答') === false && hook.state().status.indexOf('已经在引用里') >= 0,
3009
+ hook.state().status,
3010
+ )
3011
+
3012
+ // 上限:最多 4 段
3013
+ hook.quote('第二段引用', '小窗详解')
3014
+ hook.quote('第三段引用', '侧边栏网页')
3015
+ hook.quote('第四段引用', '调试')
3016
+ assert('四段引用都在', hook.quotes().length === 4, JSON.stringify(hook.quotes().map((q) => q.text)))
3017
+ assert(
3018
+ '第五段被挡下并说明原因',
3019
+ hook.quote('第五段引用', '调试') === false && hook.state().status.indexOf('最多 4 段') >= 0,
3020
+ hook.state().status,
3021
+ )
3022
+
3023
+ // 卡片上的 ✕
3024
+ const chipXs = Array.from(walk(quotesBox)).filter((n) => n.className === 'dsh-sel-quotechip-x')
3025
+ assert('每张卡片都有 ✕', chipXs.length === 4, String(chipXs.length))
3026
+ chipXs[0].dispatch('click', { stopPropagation() {} })
3027
+ await sleep(5)
3028
+ assert(
3029
+ '点 ✕ 能精确删掉那一条',
3030
+ hook.quotes().length === 3 && !hook.quotes().some((q) => q.text === '引用用例的翻译段落'),
3031
+ JSON.stringify(hook.quotes().map((q) => q.text)),
3032
+ )
3033
+
3034
+ // ② 小窗开着时,主界面上的选区也走同一个浮标
3035
+ selectText(textEl, '主界面上的这一段')
3036
+ documentStub.dispatch('mouseup', { target: textEl })
3037
+ await sleep(30)
3038
+ const onDoc = hook.quoteState()
3039
+ assert('小窗开着时主界面划词:同样浮出「❝ 引用」', onDoc.visible === true, JSON.stringify(onDoc))
3040
+ assert('来源标成「主界面选中」', !!onDoc.selection && onDoc.selection.label === '主界面选中' && onDoc.selection.source === 'document', JSON.stringify(onDoc.selection))
3041
+ quoteButton.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3042
+ await sleep(10)
3043
+ quotes = hook.quotes()
3044
+ assert(
3045
+ '主界面选中的文字挂进了同一份清单',
3046
+ quotes.length === 4 && quotes[3].text === '主界面上的这一段' && quotes[3].label === '主界面选中',
3047
+ JSON.stringify(quotes.map((q) => q.label)),
3048
+ )
3049
+ assert(
3050
+ '主界面的引用标成"会话里的"(发送前会问 host 要按轮取整的上下文)',
3051
+ quotes[3].session === true,
3052
+ JSON.stringify({ session: quotes[3].session, context: quotes[3].context.slice(0, 30) }),
3053
+ )
3054
+
3055
+ // ③ 发送:引用拼进这一条消息(气泡里也要看得见),会话里的引用先补上下文
3056
+ const callsBefore = quoteContextCalls.length
3057
+ const beforeSend = sent.length
3058
+ askBox.value = '这几段有什么关系?'
3059
+ askSend.dispatch('click', { stopPropagation() {} })
3060
+ for (let i = 0; i < 120 && sent.length === beforeSend; i += 1) await sleep(20)
3061
+ const quotePayload = sent[sent.length - 1] || {}
3062
+ assert(
3063
+ '载荷里的 question 就是拼好的那份(引用在前、问题在后)',
3064
+ String(quotePayload.question).indexOf('【引用 1】(来自小窗详解)') === 0 && /【我的问题】\n这几段有什么关系?$/.test(String(quotePayload.question)),
3065
+ String(quotePayload.question).replace(/\n/g, '⏎').slice(0, 120),
3066
+ )
3067
+ assert(
3068
+ '每段引用各带来源,按加入顺序编号',
3069
+ /【引用 2】(来自侧边栏网页)/.test(String(quotePayload.question)) &&
3070
+ /【引用 4】(来自主界面选中)/.test(String(quotePayload.question)) &&
3071
+ String(quotePayload.question).indexOf('第五段引用') < 0,
3072
+ String(quotePayload.question).replace(/\n/g, '⏎'),
3073
+ )
3074
+ assert(
3075
+ '只给会话里的引用问 host(小窗里的那几段自己就有上下文,不多打一次请求)',
3076
+ quoteContextCalls.length === callsBefore + 1 &&
3077
+ quoteContextCalls[quoteContextCalls.length - 1].text === '主界面上的这一段' &&
3078
+ quoteContextCalls[quoteContextCalls.length - 1].sessionId === 'session-stub-1',
3079
+ JSON.stringify(quoteContextCalls.slice(callsBefore)),
3080
+ )
3081
+ assert(
3082
+ '拼好的消息里带上【引用处上下文】,被引用的部分用【】标出',
3083
+ String(quotePayload.question).indexOf('【引用处上下文】(被引用的部分用【】标出)') > 0 &&
3084
+ String(quotePayload.question).indexOf('用户:【主界面上的这一段】') > 0 &&
3085
+ String(quotePayload.question).indexOf('助手:引用之后的那句回答。') > 0,
3086
+ String(quotePayload.question).replace(/\n/g, '⏎').slice(-160),
3087
+ )
3088
+ assert(
3089
+ '没有上下文的引用照旧只给原文(不硬造一段空上下文)',
3090
+ /【引用 1】(来自小窗详解)\n第二段引用\n/.test(String(quotePayload.question)),
3091
+ String(quotePayload.question).replace(/\n/g, '⏎').slice(0, 80),
3092
+ )
3093
+ assert(
3094
+ '引用用完即清(不会跟着下一轮又发一遍)',
3095
+ hook.quotes().length === 0 && quotesBox.getAttribute('data-show') === '0',
3096
+ JSON.stringify(hook.quotes()),
3097
+ )
3098
+ const quoteTurn = hook.turns().filter((t) => t.role === 'user').pop()
3099
+ assert(
3100
+ '轮次里同时留着「问了什么」和「带了哪些引用」',
3101
+ quoteTurn.text === '这几段有什么关系?' && (quoteTurn.quotes || []).length === 4 && quoteTurn.sent === quotePayload.question,
3102
+ JSON.stringify({ text: quoteTurn.text, quotes: (quoteTurn.quotes || []).length }),
3103
+ )
3104
+ assert(
3105
+ '用户气泡里画出了引用块(引用在上、问题在下)',
3106
+ textOf(chatLog).indexOf('❝ 小窗详解') >= 0 && textOf(chatLog).indexOf('这几段有什么关系?') >= 0,
3107
+ textOf(chatLog).slice(-90),
3108
+ )
3109
+ assert(
3110
+ '气泡里的引用是摘要(长引用不撑破气泡,完整文本进 title)',
3111
+ Array.from(walk(chatLog)).some((n) => n.className === 'dsh-sel-bqitem' && n.title === '第二段引用'),
3112
+ Array.from(walk(chatLog)).filter((n) => n.className === 'dsh-sel-bqitem').map((n) => n.title).join('|'),
3113
+ )
3114
+
3115
+ // ④ 带引用的那一轮要以"拼好的样子"进历史给模型(否则下一轮模型不知道"这几段"指什么)
3116
+ for (let i = 0; i < 120 && hook.state().asking !== false; i += 1) await sleep(20)
3117
+ const beforeSecond = sent.length
3118
+ hook.ask('那第三段呢?')
3119
+ for (let i = 0; i < 120 && sent.length === beforeSecond; i += 1) await sleep(20)
3120
+ const secondPayload = sent[sent.length - 1] || {}
3121
+ const carried = (secondPayload.history || []).find((t) => t.role === 'user' && String(t.text).indexOf('【引用 1】') >= 0)
3122
+ assert(
3123
+ '带引用的那一轮原样进历史(引用块不能丢)',
3124
+ !!carried && String(carried.text).indexOf('【我的问题】') > 0,
3125
+ carried ? String(carried.text).replace(/\n/g, '⏎').slice(0, 60) : JSON.stringify((secondPayload.history || []).map((t) => t.role)),
3126
+ )
3127
+
3128
+ // ④b 小窗里的对话引用:**不带上下文**,但要说清出自哪一轮(对话本身会随历史进模型)
3129
+ for (let i = 0; i < 120 && hook.state().asking !== false; i += 1) await sleep(20)
3130
+ {
3131
+ const bubbles = Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble-bot') >= 0)
3132
+ const target = bubbles[bubbles.length - 1]
3133
+ const node = Array.from(walk(target)).find((n) => n.tagName === 'P') || target
3134
+ selectText(node, '这是对追问的回答')
3135
+ documentStub.dispatch('mouseup', { target: node })
3136
+ await sleep(30)
3137
+ const state = hook.quoteState()
3138
+ assert('小窗气泡里划词:认得出在哪一轮上', state.selection && state.selection.turnIndex >= 0, JSON.stringify(state.selection))
3139
+ quoteButton.dispatch('click', { preventDefault() {}, stopPropagation() {} })
3140
+ await sleep(10)
3141
+ const panelQuote = hook.quotes().pop()
3142
+ assert(
3143
+ '对话引用的出处 = 「小窗第 N 轮回答」',
3144
+ !!panelQuote && /^小窗第 [0-9]+ 轮回答$/.test(panelQuote.label),
3145
+ panelQuote && panelQuote.label,
3146
+ )
3147
+ assert(
3148
+ '对话引用不带上下文(小窗对话本来就在历史里,重复贴没意义)',
3149
+ !!panelQuote && panelQuote.context === '' && panelQuote.session === false,
3150
+ JSON.stringify(panelQuote && { context: panelQuote.context, session: panelQuote.session }),
3151
+ )
3152
+ const composed = hook.compose()
3153
+ assert(
3154
+ '拼进消息时只写出处、不贴【引用处上下文】',
3155
+ composed.indexOf('【引用处上下文】') < 0 &&
3156
+ composed.indexOf('【引用原文】') < 0 &&
3157
+ composed.indexOf('【引用 1】(来自小窗第') === 0,
3158
+ composed.replace(/\n/g, '⏎').slice(0, 160),
3159
+ )
3160
+ // 这一段不发了,清掉,别影响后面的用例
3161
+ const lastChip = Array.from(walk(quotesBox)).filter((n) => n.className === 'dsh-sel-quotechip-x').pop()
3162
+ if (lastChip) lastChip.dispatch('click', { stopPropagation() {} })
3163
+ await sleep(5)
3164
+ }
3165
+
3166
+ // ④c host 不认(引用不在会话里 / 路由失败)→ 用客户端自己采的那份上下文,照发不误
3167
+ for (let i = 0; i < 120 && hook.state().asking !== false; i += 1) await sleep(20)
3168
+ {
3169
+ globalThis.__quoteContextFixture = 'miss'
3170
+ hook.quote('回退探测的一段', '主界面选中', { context: '客户端兜底的上下文:用户:【回退探测的一段】', session: true })
3171
+ const beforeFallback = sent.length
3172
+ hook.ask('回退探测:这段呢?')
3173
+ for (let i = 0; i < 120 && sent.length === beforeFallback; i += 1) await sleep(20)
3174
+ const fallbackPayload = sent[sent.length - 1] || {}
3175
+ assert(
3176
+ 'host 定位不到时用客户端兜底的上下文(发送不被卡住)',
3177
+ String(fallbackPayload.question).indexOf('用户:【回退探测的一段】') > 0,
3178
+ String(fallbackPayload.question).replace(/\n/g, '⏎').slice(0, 120),
3179
+ )
3180
+ globalThis.__quoteContextFixture = undefined
3181
+ }
3182
+
3183
+ // ⑤ 只挂引用、没写问题:也允许发(兜底一句人话,绝不空发)
3184
+ for (let i = 0; i < 120 && hook.state().asking !== false; i += 1) await sleep(20)
3185
+ hook.quote('只带引用的一段', '主界面选中')
3186
+ askBox.value = ''
3187
+ const beforeOnly = sent.length
3188
+ askBox.dispatch('keydown', { key: 'Enter', preventDefault() {}, stopPropagation() {} })
3189
+ for (let i = 0; i < 120 && sent.length === beforeOnly; i += 1) await sleep(20)
3190
+ const onlyPayload = sent[sent.length - 1] || {}
3191
+ assert('空输入框 + 有引用时按 Enter 能发出去', sent.length === beforeOnly + 1, `sent=${sent.length - beforeOnly}`)
3192
+ assert(
3193
+ '兜底提问只有一句,引用块照旧在前',
3194
+ String(onlyPayload.question).indexOf('【我的问题】\n就上面引用的文字,说说它在这里是什么意思。') > 0,
3195
+ String(onlyPayload.question).replace(/\n/g, '⏎').slice(-60),
3196
+ )
3197
+ const onlyTurn = hook.turns().filter((t) => t.role === 'user').pop()
3198
+ assert('气泡里显示的就是那句兜底提问', onlyTurn.text === '就上面引用的文字,说说它在这里是什么意思。', onlyTurn.text)
3199
+
3200
+ // ⑥ 助手气泡末尾的「引用整条」(平时隐身,鼠标移上去才浮出来)
3201
+ for (let i = 0; i < 120 && hook.state().asking !== false; i += 1) await sleep(20)
3202
+ const botBubbles = Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble-bot') >= 0)
3203
+ const lastBot = botBubbles[botBubbles.length - 1]
3204
+ const quoteAllBtn = Array.from(walk(lastBot)).find((n) => String(n.className).indexOf('dsh-sel-bubquote') >= 0)
3205
+ assert('助手气泡末尾有「引用整条」', !!quoteAllBtn && textOf(quoteAllBtn).indexOf('引用整条') >= 0, quoteAllBtn ? textOf(quoteAllBtn) : '未找到')
3206
+ quoteAllBtn.dispatch('click', { stopPropagation() {} })
3207
+ await sleep(10)
3208
+ const allQuotes = hook.quotes()
3209
+ const lastBotTurn = hook.turns().filter((t) => t.role === 'assistant' && t.streaming !== true).pop()
3210
+ assert(
3211
+ '点一下 → 整条回答进了引用',
3212
+ allQuotes.length === 1 && /^小窗第 [0-9]+ 轮回答$/.test(allQuotes[0].label) && allQuotes[0].text.indexOf('这是对追问的回答') >= 0,
3213
+ JSON.stringify(allQuotes.map((q) => q.label + ':' + q.text.slice(0, 16))),
3214
+ )
3215
+ assert('引用的是这一轮的原文(卡片上才是摘要)', allQuotes[0].text === lastBotTurn.text, `${allQuotes[0].text.length} vs ${String(lastBotTurn.text).length}`)
3216
+ assert(
3217
+ '「引用整条」也不带上下文(出处靠标签)',
3218
+ allQuotes[0].context === '' && allQuotes[0].session === false,
3219
+ JSON.stringify({ context: allQuotes[0].context, session: allQuotes[0].session }),
3220
+ )
3221
+
3222
+ // ⑥b 等待/流式中的那条不给「引用整条」:内容是半截的,引用它没有意义(定稿后才出现)
3223
+ {
3224
+ hook.open('quote-wait-probe', '', '等待用例')
3225
+ for (let i = 0; i < 120 && hook.state().phase !== 'done'; i += 1) await sleep(20)
3226
+ hook.ask('等待用例的问题')
3227
+ let sawStreaming = false
3228
+ for (let i = 0; i < 60; i += 1) {
3229
+ if (hook.turns().some((t) => t.streaming === true)) {
3230
+ sawStreaming = true
3231
+ break
3232
+ }
3233
+ await sleep(10)
3234
+ }
3235
+ const waiting = Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble-bot') >= 0).pop()
3236
+ assert(
3237
+ '等待/流式中的那条不给「引用整条」',
3238
+ sawStreaming &&
3239
+ !!waiting &&
3240
+ !Array.from(walk(waiting)).some((n) => String(n.className).indexOf('dsh-sel-bubquote') >= 0),
3241
+ `${sawStreaming ? 'streaming' : '未抓到流式态'} · ${waiting ? textOf(waiting).slice(0, 20) : '未找到'}`,
3242
+ )
3243
+ for (let i = 0; i < 160 && hook.state().asking !== false; i += 1) await sleep(20)
3244
+ const settled = Array.from(walk(chatLog)).filter((n) => n.className.indexOf('dsh-sel-bubble-bot') >= 0).pop()
3245
+ assert(
3246
+ '定稿之后「引用整条」才出现',
3247
+ !!settled && Array.from(walk(settled)).some((n) => String(n.className).indexOf('dsh-sel-bubquote') >= 0),
3248
+ settled ? textOf(settled).slice(0, 20) : '未找到',
3249
+ )
3250
+ }
3251
+
3252
+ // ⑦ 小窗收起来:浮标回到「✦ 解读」(引用只在开着小窗时有意义)
3253
+ hook.close()
3254
+ selectText(textEl, '主界面上的另一次选中')
3255
+ documentStub.dispatch('mouseup', { target: textEl })
3256
+ await sleep(30)
3257
+ assert(
3258
+ '小窗收起后:浮标回到「✦ 解读」,引用浮标不再出现',
3259
+ quoteButton.style.display === 'none' && button.style.display === 'inline-flex',
3260
+ `quote=${quoteButton.style.display} read=${button.style.display}`,
3261
+ )
3262
+
3263
+ // ⑧ 换一段选中文字 = 换一次对话:上一段攒的引用不该跟过来
3264
+ hook.open('slipped', '', '换选区')
3265
+ await sleep(30)
3266
+ assert(
3267
+ '换选区后引用清单清空',
3268
+ hook.quotes().length === 0 && quotesBox.getAttribute('data-show') === '0',
3269
+ JSON.stringify(hook.quotes()),
3270
+ )
3271
+
3272
+ windowStub.getSelection = restoreGetSelection
3273
+ hook.close()
3274
+ }
3275
+
3276
+ // ═════════════════════ 语音输入(麦克风 → 文字 → 输入框) ═════════════════════
3277
+ //
3278
+ // UI 与主会话 ui-voice-input 同一套:平时是输入框右边的 🎤,开始录之后工具行换成
3279
+ // 录音行 ——`✕` | 实时波形 | `■`;请求权限/识别中是「呼吸点 + 文案」;出错是「文案 + 行内动作」。
3280
+ // 桩里验四件事:
3281
+ // ① 状态机与录音行的形态(哪一格在、哪一格不在,工具行左边三样有没有让位);
3282
+ // ② 波形真的在动(80 根线、静音基线、有声变高、从右往左推);
3283
+ // ③ 文字**插进输入框**(追加在光标处,不覆盖用户已经写了一半的话);
3284
+ // ④ 该松麦克风的时候一定要松(取消 / 失焦 / 收起小窗 / 这条消息发出去)。
3285
+ //
3286
+ // 真实识别(真模型 + 真音频)不在这里:见 scripts/test-speech.mjs 与 scripts/smoke-voice.mjs。
3287
+ function waitVoice(predicate, ms = 1500) {
3288
+ const started = Date.now()
3289
+ const step = async () => {
3290
+ if (predicate()) return true
3291
+ if (Date.now() - started > ms) return !!predicate()
3292
+ await sleep(10)
3293
+ return step()
3294
+ }
3295
+ return step()
3296
+ }
3297
+
3298
+ /** 从 bundle 源码里抠一个具名函数(与 test-bridge.mjs 同一套配平逻辑)。 */
3299
+ function sliceFunction(text, name) {
3300
+ const start = text.indexOf('function ' + name + '(')
3301
+ if (start < 0) throw new Error('bundle 里找不到函数:' + name)
3302
+ let depth = 0
3303
+ for (let i = text.indexOf('{', start); i < text.length; i += 1) {
3304
+ const ch = text[i]
3305
+ if (ch === '{') depth += 1
3306
+ else if (ch === '}') {
3307
+ depth -= 1
3308
+ if (depth === 0) return text.slice(start, i + 1)
3309
+ } else if (ch === "'" || ch === '"' || ch === '`') {
3310
+ for (i += 1; i < text.length; i += 1) {
3311
+ if (text[i] === '\\') i += 1
3312
+ else if (text[i] === ch) break
3313
+ }
3314
+ }
3315
+ }
3316
+ throw new Error('函数没配平:' + name)
3317
+ }
3318
+
3319
+ {
3320
+ // ── 浏览器音频桩 ──────────────────────────────────────────────
3321
+ const micCalls = []
3322
+ const micTracks = []
3323
+ let micDenied = false
3324
+ let micDeferred = null
3325
+ const makeTrack = () => {
3326
+ const track = {
3327
+ stopped: false,
3328
+ stop() {
3329
+ this.stopped = true
3330
+ },
3331
+ }
3332
+ micTracks.push(track)
3333
+ return track
3334
+ }
3335
+ const makeStream = () => {
3336
+ const track = makeTrack()
3337
+ return { getTracks: () => [track] }
3338
+ }
3339
+ navigatorStub.mediaDevices = {
3340
+ getUserMedia(constraints) {
3341
+ micCalls.push(constraints)
3342
+ if (micDenied) {
3343
+ const error = new Error('Permission denied')
3344
+ error.name = 'NotAllowedError'
3345
+ return Promise.reject(error)
3346
+ }
3347
+ if (micDeferred) {
3348
+ const pending = micDeferred
3349
+ micDeferred = null
3350
+ return pending.promise.then(() => makeStream())
3351
+ }
3352
+ return Promise.resolve(makeStream())
3353
+ },
3354
+ }
3355
+ /**
3356
+ * 电平序列:头几拍是静音(刚点下麦克风还没开口),之后逐次抬高。
3357
+ * 这样既能看到"静音 = 2px 基线",也能看到"新电平从右边进来"(恒定电平看不出推进)。
3358
+ */
3359
+ let levelStep = 0
3360
+ const nextLevel = () => {
3361
+ levelStep += 1
3362
+ if (levelStep <= 3) return 0
3363
+ return Math.min(0.06 + (levelStep - 3) * 0.012, 0.5)
3364
+ }
3365
+ class MediaRecorderStub {
3366
+ constructor(stream) {
3367
+ this.stream = stream
3368
+ this.state = 'recording'
3369
+ this.mimeType = 'audio/webm;codecs=opus'
3370
+ MediaRecorderStub.last = this
3371
+ }
3372
+ start() {
3373
+ this.state = 'recording'
3374
+ }
3375
+ stop() {
3376
+ this.state = 'inactive'
3377
+ const chunk = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])], { type: 'audio/webm' })
3378
+ if (this.ondataavailable) this.ondataavailable({ data: chunk })
3379
+ if (this.onstop) this.onstop()
3380
+ }
3381
+ }
3382
+ class AudioContextStub {
3383
+ constructor() {
3384
+ this.state = 'running'
3385
+ this.closed = false
3386
+ /** 真实浏览器里通常是 44100/48000:实时字幕的线性重采样要按这个走 */
3387
+ this.sampleRate = 48000
3388
+ this.processors = []
3389
+ AudioContextStub.instances.push(this)
3390
+ }
3391
+ createGain() {
3392
+ return { gain: { value: 1 }, connect() {}, disconnect() {} }
3393
+ }
3394
+ createScriptProcessor(size) {
3395
+ const node = { size, onaudioprocess: null, connect() {}, disconnect() {} }
3396
+ this.processors.push(node)
3397
+ return node
3398
+ }
3399
+ createAnalyser() {
3400
+ return {
3401
+ fftSize: 256,
3402
+ getFloatTimeDomainData(array) {
3403
+ const level = nextLevel()
3404
+ for (let i = 0; i < array.length; i += 1) array[i] = level
3405
+ },
3406
+ }
3407
+ }
3408
+ createMediaStreamSource() {
3409
+ return { connect() {} }
3410
+ }
3411
+ decodeAudioData(_buffer, ok) {
3412
+ // 2 秒的"解码结果":下游只关心 duration 与重采样
3413
+ if (ok) ok({ duration: 2, sampleRate: 48000, numberOfChannels: 2 })
3414
+ return Promise.resolve({ duration: 2, sampleRate: 48000, numberOfChannels: 2 })
3415
+ }
3416
+ close() {
3417
+ this.closed = true
3418
+ return Promise.resolve()
3419
+ }
3420
+ }
3421
+ AudioContextStub.instances = []
3422
+ class OfflineAudioContextStub {
3423
+ constructor(channels, frames, rate) {
3424
+ this.frames = frames
3425
+ this.sampleRate = rate
3426
+ OfflineAudioContextStub.last = this
3427
+ }
3428
+ createBufferSource() {
3429
+ return { buffer: null, connect() {}, start() {} }
3430
+ }
3431
+ startRendering() {
3432
+ const data = new Float32Array(this.frames)
3433
+ for (let i = 0; i < data.length; i += 1) data[i] = Math.sin(i / 20) * 0.5
3434
+ return Promise.resolve({ getChannelData: () => data, duration: this.frames / this.sampleRate })
3435
+ }
3436
+ }
3437
+ windowStub.MediaRecorder = MediaRecorderStub
3438
+ windowStub.AudioContext = AudioContextStub
3439
+ windowStub.OfflineAudioContext = OfflineAudioContextStub
3440
+
3441
+ // ── 面板:打开到"翻译就绪、composer 可见" ──────────────────────
3442
+ hook.open('voice-probe', '', '语音输入')
3443
+ await waitVoice(() => hook.state().phase === 'done')
3444
+ const askRowNode = Array.from(walk(panel)).find((n) => String(n.className).split(/\s+/).includes('dsh-sel-ask'))
3445
+ assert('语音用例:composer 已可见', !!askRowNode && askRowNode.style.display !== 'none', askRowNode && askRowNode.style.display)
3446
+
3447
+ const micButton = hook.voiceNode()
3448
+ const nodes = hook.voiceNodes()
3449
+ const webModeNode = Array.from(walk(panel)).find((n) => String(n.className).split(/\s+/).includes('dsh-sel-pref'))
3450
+ const modelPillNode = Array.from(walk(panel)).find((n) => String(n.className).split(/\s+/).includes('dsh-sel-picker'))
3451
+ const micNode = Array.from(walk(panel)).find((n) => String(n.className).split(/\s+/).includes('dsh-sel-mic'))
3452
+ const toolsRow = micNode && micNode.parentNode
3453
+ assert('composer 里有麦克风按钮(和发送键同属工具行)', !!micNode && !!nodes.row, String(!!micNode))
3454
+ assert(
3455
+ '麦克风在发送键左边(贴 composer 主操作区)',
3456
+ !!toolsRow && toolsRow.children.indexOf(micNode) < toolsRow.children.indexOf(askSend),
3457
+ toolsRow ? `${toolsRow.children.indexOf(micNode)} < ${toolsRow.children.indexOf(askSend)}` : 'no row',
3458
+ )
3459
+ assert(
3460
+ '初始:录音行没出来、🎤 可见、状态文案是空的',
3461
+ hook.voice().phase === 'idle' && hook.voice().capture === false && nodes.row.style.display === 'none' && hook.voice().activity === '',
3462
+ JSON.stringify(hook.voice()).slice(0, 120),
3463
+ )
3464
+ assert('自检钩子能看到"这个环境支持录音"', hook.voice().supported === true, String(hook.voice().supported))
3465
+
3466
+ // ── ① 点一下:请求权限 → 录音(工具行换成录音行)────────────────
3467
+ const waveBarsCount = nodes.wave.children.length
3468
+ micButton.dispatch('click', { stopPropagation() {} })
3469
+ await waitVoice(() => hook.voice().phase === 'requesting')
3470
+ assert('点 🎤 → 请求权限:录音行出来(和主会话一样,工具行整行换掉)', hook.voice().capture === true, String(hook.voice().capture))
3471
+ assert('请求权限时:文案 + 呼吸点,波形与 ■ 都不出现', hook.voice().activity.indexOf('请允许使用麦克风') >= 0 && hook.voice().dot === true && hook.voice().waveform === false && hook.voice().stop === false, hook.voice().activity)
3472
+ assert(
3473
+ '录音行出来时,输出偏好 / 模型 / 🎤 让位(一行只讲一件事)',
3474
+ webModeNode.style.display === 'none' && modelPillNode.style.display === 'none' && micButton.style.display === 'none',
3475
+ `${webModeNode.style.display} / ${modelPillNode.style.display} / ${micButton.style.display}`,
3476
+ )
3477
+ assert('录音行是 ✕ 打头(取消在左边)', nodes.row.children[0] === nodes.cancel, String(nodes.row.children.indexOf(nodes.cancel)))
3478
+ assert('波形是 80 根线(和主会话同一个形状)', waveBarsCount === 80, String(waveBarsCount))
3479
+
3480
+ await waitVoice(() => hook.voice().phase === 'recording')
3481
+ assert('拿到麦克风 → 进入录音态', hook.voice().phase === 'recording', hook.voice().phase)
3482
+ assert('录音时:波形出现、状态文案收起、■ 出现(✕ | 波形 | ■)', hook.voice().waveform === true && hook.voice().stop === true && nodes.activity.style.display === 'none', JSON.stringify({ wave: hook.voice().waveform, stop: hook.voice().stop }))
3483
+ assert('只向浏览器要了麦克风(不要摄像头)', micCalls.length === 1 && micCalls[0].video === false, JSON.stringify(micCalls[0]))
3484
+ assert(
3485
+ '静音时波形是一条虚线(没收到声音的格子都是 2px 基线)',
3486
+ hook.voice().bars.filter((height) => height === 2).length >= 70,
3487
+ JSON.stringify(hook.voice().bars.slice(0, 6)),
3488
+ )
3489
+
3490
+ await sleep(320)
3491
+ const bars = hook.voice().bars
3492
+ assert('有声之后波形变高(1+min(1,amp*5)*17)', Math.max.apply(null, bars) > 3, String(Math.max.apply(null, bars)))
3493
+ assert(
3494
+ '新的电平从右边进来、整排往左推(最右边最高)',
3495
+ bars[0] > bars[bars.length - 1],
3496
+ `${bars[0]} vs ${bars[bars.length - 1]}`,
3497
+ )
3498
+ assert('录音中不显示秒数文案(主会话也是只看波形)', hook.voice().activity === '', JSON.stringify(hook.voice().activity))
3499
+
3500
+ // ── ② 点 ■ 结束:识别 → 文字插进输入框 ─────────────────────────
3501
+ const firstTrack = micTracks[micTracks.length - 1]
3502
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3503
+ await waitVoice(() => hook.voice().phase === 'transcribing')
3504
+ assert('点 ■ → 识别中:文案 + 呼吸点,波形与 ■ 收起', hook.voice().activity.indexOf('识别中') >= 0 && hook.voice().dot === true && hook.voice().waveform === false && hook.voice().stop === false, hook.voice().activity)
3505
+ assert('录音结束 → 松开麦克风(音轨停掉)', firstTrack.stopped === true)
3506
+ await waitVoice(() => hook.voice().lastText !== '')
3507
+ assert('录音结束后 AudioContext 关掉(不留音频线程)', AudioContextStub.instances.every((ctx) => ctx.closed === true), String(AudioContextStub.instances.length))
3508
+ assert('重采样成 16kHz(host 只收这一种)', OfflineAudioContextStub.last && OfflineAudioContextStub.last.sampleRate === 16000, String(OfflineAudioContextStub.last && OfflineAudioContextStub.last.sampleRate))
3509
+ assert('识别请求发到了 host(带 base64 音频、不带文件路径之类)', speechTranscribeCalls.length === 1 && typeof speechTranscribeCalls[0].audioBase64 === 'string', Object.keys(speechTranscribeCalls[0] || {}).join(','))
3510
+ assert('识别出来的文字进了输入框', askBox.value.indexOf('这段是语音转出来的问题') >= 0, askBox.value)
3511
+ assert('插入后光标在末尾(接着就能改 / 接着说)', askBox.selectionStart === askBox.value.length, `${askBox.selectionStart}/${askBox.value.length}`)
3512
+ assert('插入后发送键可点', askSend.disabled === false, String(askSend.disabled))
3513
+ await waitVoice(() => hook.voice().phase === 'idle')
3514
+ assert('成功之后立刻回 idle:录音行收起、🎤 回来、不留多余提示(和主会话一致)', hook.voice().phase === 'idle' && hook.voice().capture === false && micButton.style.display === '' && hook.voice().activity === '', JSON.stringify({ phase: hook.voice().phase, activity: hook.voice().activity }))
3515
+
3516
+ // ── ③ 追加而不是覆盖:用户已经写了一半时接着说 ────────────────
3517
+ hook.askValue('先写的一句')
3518
+ speechTranscript = { ok: true, text: '后面补的一句' }
3519
+ micButton.dispatch('click', { stopPropagation() {} })
3520
+ await waitVoice(() => hook.voice().phase === 'recording')
3521
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3522
+ await waitVoice(() => hook.voice().lastText === '后面补的一句')
3523
+ assert('已有内容时是追加(中间补一个空格)', askBox.value === '先写的一句 后面补的一句', askBox.value)
3524
+ await waitVoice(() => hook.voice().phase === 'idle')
3525
+ hook.askValue('')
3526
+
3527
+ // ── ④ 失败路径:权限被拒 → feedback + 🎤 重录 ──────────────────
3528
+ micDenied = true
3529
+ const tracksBeforeDenied = micTracks.length
3530
+ micButton.dispatch('click', { stopPropagation() {} })
3531
+ await waitVoice(() => hook.voice().phase === 'feedback')
3532
+ assert('权限被拒:录音行里给原因(不是 DOMException 原文)', hook.voice().activity.indexOf('麦克风权限未开启') >= 0 && hook.voice().activityTone === 'error', `${hook.voice().activityTone} ${hook.voice().activity}`)
3533
+ assert('权限被拒时一个音轨都没拿到', micTracks.length === tracksBeforeDenied, `${micTracks.length} vs ${tracksBeforeDenied}`)
3534
+ assert('失败时给「重新录音」🎤(主会话同款行内动作)', hook.voice().action === 'retry' && nodes.action.children.length === 1, `${hook.voice().action} / ${nodes.action.children.length}`)
3535
+ micDenied = false
3536
+
3537
+ // ── ⑤ ✕ 关掉这条提示(feedback → idle,工具行还回去)──────────
3538
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3539
+ await sleep(20)
3540
+ assert('点 ✕ 关掉提示:回 idle、工具行还原(输出偏好 / 模型 / 🎤 都回来)', hook.voice().phase === 'idle' && hook.voice().capture === false && webModeNode.style.display === '' && modelPillNode.style.display === '' && micButton.style.display === '', `${hook.voice().phase} ${webModeNode.style.display}`)
3541
+
3542
+ // ── ⑥ 模型没准备好 → 「准备模型」行内动作 ───────────────────────
3543
+ const goodCatalog = speechCatalog
3544
+ speechCatalog = {
3545
+ ...goodCatalog,
3546
+ available: false,
3547
+ reason: 'unprepared',
3548
+ providers: [{ ...goodCatalog.providers[0], phase: 'unprepared' }],
3549
+ }
3550
+ micButton.dispatch('click', { stopPropagation() {} })
3551
+ await waitVoice(() => hook.voice().action === 'prepare')
3552
+ assert('模型没准备:说清 + 给「准备模型」按钮', hook.voice().activity.indexOf('语音模型还没准备好') >= 0 && hook.voice().activityTone === 'warn', `${hook.voice().activityTone} ${hook.voice().activity}`)
3553
+ assert('没准备好时不进录音态(不在没模型时占着麦克风)', hook.voice().phase === 'feedback' && micTracks.length === tracksBeforeDenied, hook.voice().phase)
3554
+ const prepareBtn = nodes.action.children[0]
3555
+ assert('提示里的按钮确实画出来了', !!prepareBtn && prepareBtn.textContent === '准备模型', prepareBtn ? prepareBtn.textContent : 'none')
3556
+ speechCatalog = { ...goodCatalog, available: true, providers: [{ ...goodCatalog.providers[0], phase: 'ready' }] }
3557
+ prepareBtn.dispatch('click', { stopPropagation() {} })
3558
+ await waitVoice(() => speechPrepareCalls > 0)
3559
+ assert('点「准备模型」→ 通知 host 去准备(下载在 host 上跑)', speechPrepareCalls === 1, String(speechPrepareCalls))
3560
+ assert('准备中:复用"进行中"形态(文案 + 呼吸点)', hook.voice().activity.indexOf('正在准备语音模型') >= 0 && hook.voice().dot === true, hook.voice().activity)
3561
+ await waitVoice(() => hook.voice().activity.indexOf('已就绪') >= 0, 3000)
3562
+ assert('准备完成后提示"可以开始说话"(行内动作换成 🎤)', hook.voice().activity.indexOf('已就绪') >= 0 && hook.voice().action === 'retry', `${hook.voice().action} ${hook.voice().activity}`)
3563
+ speechCatalog = goodCatalog
3564
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3565
+ await sleep(20)
3566
+
3567
+ // ── ⑦ 失败路径:识别失败 / 没听清 ──────────────────────────────
3568
+ speechTranscript = { ok: false, code: 'failed', error: '识别服务挂了' }
3569
+ micButton.dispatch('click', { stopPropagation() {} })
3570
+ await waitVoice(() => hook.voice().phase === 'recording')
3571
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3572
+ await waitVoice(() => hook.voice().phase === 'feedback')
3573
+ assert('识别失败:原样说清原因', hook.voice().activityTone === 'error' && hook.voice().activity.indexOf('识别服务挂了') >= 0, hook.voice().activity)
3574
+ assert('识别失败不会往输入框里塞东西', askBox.value === '', JSON.stringify(askBox.value))
3575
+
3576
+ speechTranscript = { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' }
3577
+ micButton.dispatch('click', { stopPropagation() {} })
3578
+ await waitVoice(() => hook.voice().phase === 'recording')
3579
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3580
+ await waitVoice(() => hook.voice().activity.indexOf('未识别到语音') >= 0)
3581
+ assert('没听清:给主会话那句「未识别到语音」+ 可重录', hook.voice().activity === '未识别到语音' && hook.voice().activityTone === 'warn' && hook.voice().action === 'retry', `${hook.voice().activityTone} ${hook.voice().activity}`)
3582
+
3583
+ // 识别中点 ✕ = 取消(不能有文字冒出来)
3584
+ speechTranscript = { ok: true, text: '这段不该被插进去' }
3585
+ micButton.dispatch('click', { stopPropagation() {} })
3586
+ await waitVoice(() => hook.voice().phase === 'recording')
3587
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3588
+ await waitVoice(() => hook.voice().phase === 'transcribing')
3589
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3590
+ await sleep(80)
3591
+ assert('识别中点 ✕ = 取消(回 idle)', hook.voice().phase === 'idle' && hook.voice().capture === false, hook.voice().phase)
3592
+ assert('取消之后晚到的结果不会插进输入框', askBox.value.indexOf('这段不该被插进去') < 0, JSON.stringify(askBox.value))
3593
+
3594
+ // ── ⑧ 权限还没回来就取消:晚到的授权不能变成"在录音" ────────────
3595
+ micDeferred = Promise.withResolvers()
3596
+ speechTranscript = { ok: true, text: '不该出现的第二段' }
3597
+ const beforeCancelCalls = micCalls.length
3598
+ micButton.dispatch('click', { stopPropagation() {} })
3599
+ await waitVoice(() => micCalls.length === beforeCancelCalls + 1)
3600
+ assert('点了麦克风之后到授权回来之前是「请求中」', hook.voice().phase === 'requesting', `${hook.voice().phase} calls=${micCalls.length}`)
3601
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3602
+ assert('请求中点 ✕ = 取消(回 idle)', hook.voice().phase === 'idle' && hook.voice().capture === false, hook.voice().phase)
3603
+ const tracksBeforeLate = micTracks.length
3604
+ micDeferred && micDeferred.resolve()
3605
+ await sleep(80)
3606
+ assert(
3607
+ '取消后晚到的授权不会偷偷开录(也不留下音轨)',
3608
+ hook.voice().phase === 'idle' && micTracks.length === tracksBeforeLate,
3609
+ `${hook.voice().phase} tracks=${micTracks.length - tracksBeforeLate}`,
3610
+ )
3611
+
3612
+ // ── ⑨ 这条消息发出去了:正在录的那段收掉 ───────────────────────
3613
+ micButton.dispatch('click', { stopPropagation() {} })
3614
+ await waitVoice(() => hook.voice().phase === 'recording')
3615
+ const sendTrack = micTracks[micTracks.length - 1]
3616
+ hook.ask('边录边说的问题')
3617
+ await sleep(60)
3618
+ assert('发送消息时正在录的那段被收掉(音轨停掉)', sendTrack.stopped === true, String(sendTrack.stopped))
3619
+ assert('并说明为什么停(不是悄悄停)', hook.voice().activity.indexOf('已停止录音') >= 0, hook.voice().activity)
3620
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3621
+ assert('这句交代过两秒自己收起(不占着录音行)', hook.voice().capture === false, String(hook.voice().capture))
3622
+
3623
+ // ── ⑩ 切走窗口(blur)= 停止录音,识别不打断 ───────────────────
3624
+ micButton.dispatch('click', { stopPropagation() {} })
3625
+ await waitVoice(() => hook.voice().phase === 'recording')
3626
+ const blurTrack = micTracks[micTracks.length - 1]
3627
+ windowStub.dispatch('blur', {})
3628
+ await sleep(30)
3629
+ assert('窗口失焦:录音取消、麦克风松开(主会话同款)', blurTrack.stopped === true && hook.voice().phase === 'idle', `${blurTrack.stopped} ${hook.voice().phase}`)
3630
+
3631
+ // ── ⑪ 收起小窗 = 立刻松麦克风 ─────────────────────────────────
3632
+ micButton.dispatch('click', { stopPropagation() {} })
3633
+ await waitVoice(() => hook.voice().phase === 'recording')
3634
+ const openTrack = micTracks[micTracks.length - 1]
3635
+ hook.close()
3636
+ await sleep(30)
3637
+ assert('收起小窗:音轨停掉、状态回 idle', openTrack.stopped === true && hook.voice().phase === 'idle', `${openTrack.stopped} ${hook.voice().phase}`)
3638
+ assert('收起小窗:录音行一起收掉(不留半句"正在录音")', hook.voice().capture === false && hook.voice().activity === '', JSON.stringify({ capture: hook.voice().capture, activity: hook.voice().activity }))
3639
+
3640
+ // ══════════ ⑬ 实时字幕:半句预览 + 停顿定稿(采样水龙头驱动)══════════
3641
+ // 真浏览器里 onaudioprocess 会自己喂采样;桩里由测试直接喂(同一段代码路径)。
3642
+ const feedTap = (node, seconds, amplitude) => {
3643
+ const frames = Math.round(seconds * 48000)
3644
+ const chunk = new Float32Array(frames)
3645
+ for (let i = 0; i < frames; i += 1) chunk[i] = amplitude
3646
+ node.onaudioprocess({ inputBuffer: { getChannelData: () => chunk } })
3647
+ }
3648
+
3649
+ {
3650
+ const before = askBox.value
3651
+ micButton.dispatch('click', { stopPropagation() {} })
3652
+ await waitVoice(() => hook.voice().phase === 'recording')
3653
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3654
+ const tap = context.processors[context.processors.length - 1]
3655
+ assert('录音时接上了采样水龙头(实时字幕的前提)', !!tap && hook.voice().live.active === true && hook.voice().live.disabled === false, JSON.stringify(hook.voice().live).slice(0, 120))
3656
+
3657
+ // 说 1.2 秒(响)→ 第一拍半句预览
3658
+ speechTranscriptQueue = [{ ok: true, text: '这半句还在说' }]
3659
+ feedTap(tap, 1.2, 0.3)
3660
+ await waitVoice(() => hook.voice().live.passes.preview >= 1, 2000)
3661
+ await waitVoice(() => hook.voice().live.preview !== '', 2000)
3662
+ assert('说话时字就往输入框里长(半句预览)', hook.voice().live.preview === '这半句还在说' && askBox.value.indexOf('这半句还在说') >= 0, `${hook.voice().live.preview} / ${JSON.stringify(askBox.value)}`)
3663
+ assert('预览是插在原来那段文字后面,不是另起一段', askBox.value.startsWith(before), JSON.stringify(askBox.value))
3664
+
3665
+ // 静音 0.9 秒 → 这句说完了:定稿
3666
+ speechTranscriptQueue = [{ ok: true, text: '这句定稿了' }]
3667
+ feedTap(tap, 0.9, 0)
3668
+ await waitVoice(() => hook.voice().live.passes.commit >= 1, 2500)
3669
+ await waitVoice(() => hook.voice().live.committed !== '', 2500)
3670
+ assert('停顿 0.6 秒以上 → 这句定稿(预览换成定稿)', hook.voice().live.committed === '这句定稿了' && hook.voice().live.preview === '', JSON.stringify({ committed: hook.voice().live.committed, preview: hook.voice().live.preview }))
3671
+ assert('定稿的文字留在输入框里', askBox.value.indexOf('这句定稿了') >= 0, JSON.stringify(askBox.value))
3672
+
3673
+ // 还在说(持续有声,不产生停顿)→ 第二句以"半句预览"接在定稿后面
3674
+ hook.voiceTick()
3675
+ speechTranscriptRepeat = { ok: true, text: '下一句也在跟' }
3676
+ const keepTalking = setInterval(() => feedTap(tap, 0.2, 0.3), 120)
3677
+ await waitVoice(() => hook.voice().live.preview === '下一句也在跟', 4000)
3678
+ assert('定稿不丢、新的半句接在后面', hook.voice().live.committed === '这句定稿了' && hook.voice().live.preview === '下一句也在跟', JSON.stringify({ committed: hook.voice().live.committed, preview: hook.voice().live.preview }))
3679
+ clearInterval(keepTalking)
3680
+ speechTranscriptRepeat = null
3681
+ assert('输入框里是「定稿 + 空格 + 预览」', askBox.value.indexOf('这句定稿了 下一句也在跟') >= 0, JSON.stringify(askBox.value))
3682
+
3683
+ // ■ 停止:整段识别以定稿开头 → 只补后半句,定稿的字一个不动
3684
+ speechTranscriptQueue = [{ ok: true, text: '这句定稿了 下一句也在跟,而且整段还多说了几个字' }]
3685
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3686
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3687
+ assert(
3688
+ '停止时以整段为准收口:定稿部分原样保留,只把后面补全',
3689
+ askBox.value.indexOf('这句定稿了 下一句也在跟,而且整段还多说了几个字') >= 0 && hook.voice().live.rewritten === false,
3690
+ JSON.stringify(askBox.value),
3691
+ )
3692
+ assert('停止后实时层关掉(不再刷新)', hook.voice().live.active === false, String(hook.voice().live.active))
3693
+ }
3694
+
3695
+ // ── ⑭ 整段识别"对不上"时:以整段为准整块替换(并记一笔)──────────
3696
+ {
3697
+ hook.askValue('')
3698
+ speechTranscriptQueue = [{ ok: true, text: '实时那段' }, { ok: true, text: '实时定稿的那句' }]
3699
+ micButton.dispatch('click', { stopPropagation() {} })
3700
+ await waitVoice(() => hook.voice().phase === 'recording')
3701
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3702
+ const tap = context.processors[context.processors.length - 1]
3703
+ feedTap(tap, 1.2, 0.3)
3704
+ await waitVoice(() => hook.voice().live.passes.preview >= 1, 2000)
3705
+ feedTap(tap, 0.9, 0)
3706
+ await waitVoice(() => hook.voice().live.committed !== '', 3000)
3707
+ assert('(前置)这一句已经定稿', hook.voice().live.committed === '实时定稿的那句', JSON.stringify(hook.voice().live.committed))
3708
+ speechTranscriptQueue = [{ ok: true, text: '整段识别给的是完全不同的句子' }]
3709
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3710
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3711
+ assert('整段与实时对不上时:整块替换,并以自检记一笔', askBox.value === '整段识别给的是完全不同的句子' && hook.voice().live.rewritten === true, JSON.stringify({ value: askBox.value, rewritten: hook.voice().live.rewritten }))
3712
+ }
3713
+
3714
+ // ── ⑮ ✕ 取消 = 只停,已经说出来的字留着(用户明确要的语义)──────────
3715
+ {
3716
+ hook.askValue('手写的一句')
3717
+ speechTranscriptQueue = [{ ok: true, text: '取消之后这句要留着' }]
3718
+ micButton.dispatch('click', { stopPropagation() {} })
3719
+ await waitVoice(() => hook.voice().phase === 'recording')
3720
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3721
+ const tap = context.processors[context.processors.length - 1]
3722
+ feedTap(tap, 1.2, 0.3)
3723
+ await waitVoice(() => hook.voice().live.preview === '取消之后这句要留着', 3000)
3724
+ await waitVoice(() => hook.voice().live.passes.preview >= 1, 500)
3725
+ assert('取消前:预览已经在输入框里', askBox.value.indexOf('取消之后这句要留着') >= 0, JSON.stringify(askBox.value))
3726
+ const track = micTracks[micTracks.length - 1]
3727
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3728
+ await sleep(40)
3729
+ assert('✕ 取消 = 停录、松开麦克风', hook.voice().phase === 'idle' && hook.voice().capture === false && track.stopped === true, JSON.stringify({ phase: hook.voice().phase, stopped: track.stopped }))
3730
+ assert('✕ 取消 = 已经说出来的字**留着**(连着手写那句一起)', askBox.value === '手写的一句 取消之后这句要留着', JSON.stringify(askBox.value))
3731
+ assert('取消后实时层关掉(不再更新那段文字)', hook.voice().live.active === false, JSON.stringify({ active: hook.voice().live.active }))
3732
+ }
3733
+
3734
+ // ── ⑯ 失焦 / 收起小窗:录音停下,屏幕上的字留着 ─────────────────
3735
+ {
3736
+ hook.askValue('')
3737
+ speechTranscriptQueue = [{ ok: true, text: '切走也别丢字' }]
3738
+ micButton.dispatch('click', { stopPropagation() {} })
3739
+ await waitVoice(() => hook.voice().phase === 'recording')
3740
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3741
+ const tap = context.processors[context.processors.length - 1]
3742
+ feedTap(tap, 1.2, 0.3)
3743
+ await waitVoice(() => hook.voice().live.preview === '切走也别丢字', 3000)
3744
+ windowStub.dispatch('blur', {})
3745
+ await sleep(40)
3746
+ assert('失焦停录,但已经写进输入框的字留着', hook.voice().phase === 'idle' && askBox.value.indexOf('切走也别丢字') >= 0, JSON.stringify({ phase: hook.voice().phase, value: askBox.value }))
3747
+ hook.askValue('')
3748
+ }
3749
+
3750
+ // ── ⑰ 用户在实时那段里改字 → 实时层让位(不跟用户抢)────────────
3751
+ {
3752
+ speechTranscriptQueue = [{ ok: true, text: '我是预览' }]
3753
+ micButton.dispatch('click', { stopPropagation() {} })
3754
+ await waitVoice(() => hook.voice().phase === 'recording')
3755
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3756
+ const tap = context.processors[context.processors.length - 1]
3757
+ feedTap(tap, 1.2, 0.3)
3758
+ await waitVoice(() => hook.voice().live.preview === '我是预览' && askBox.value.indexOf('我是预览') >= 0, 3000)
3759
+ askBox.value = askBox.value.replace('我是预览', '我改成别的了')
3760
+ askBox.dispatch('input', {})
3761
+ feedTap(tap, 1.2, 0.3)
3762
+ hook.voiceTick()
3763
+ await sleep(300)
3764
+ assert('用户改了实时那段 → 实时层让位(不改他的字)', hook.voice().live.disabled === true && hook.voice().live.reason === 'edited' && askBox.value.indexOf('我改成别的了') >= 0, JSON.stringify({ reason: hook.voice().live.reason, value: askBox.value }))
3765
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3766
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3767
+ assert('让位之后停止仍然出最终文字(退回普通插入)', askBox.value.indexOf('我是预览'.slice(0, 0)) >= 0 && askBox.value.indexOf('我改成别的了') >= 0, JSON.stringify(askBox.value))
3768
+ hook.askValue('')
3769
+ }
3770
+
3771
+ // ── ⑱ 没有采样水龙头(老浏览器):功能自动退回"停止后出字" ───────
3772
+ {
3773
+ const realScriptProcessor = AudioContextStub.prototype.createScriptProcessor
3774
+ AudioContextStub.prototype.createScriptProcessor = undefined
3775
+ speechTranscriptQueue = [{ ok: true, text: '没有水龙头也能出字' }]
3776
+ micButton.dispatch('click', { stopPropagation() {} })
3777
+ await waitVoice(() => hook.voice().phase === 'recording')
3778
+ assert('拿不到水龙头:实时层自己关掉(不报错、不空转)', hook.voice().live.active === false && hook.voice().live.disabled === true && hook.voice().live.reason === 'no-tap', JSON.stringify(hook.voice().live).slice(0, 120))
3779
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3780
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3781
+ assert('停止后照样把整段文字插进输入框(老行为不变)', askBox.value.indexOf('没有水龙头也能出字') >= 0, JSON.stringify(askBox.value))
3782
+ AudioContextStub.prototype.createScriptProcessor = realScriptProcessor
3783
+ hook.askValue('')
3784
+ }
3785
+
3786
+ // ── ⑲ 云端识别器:不做实时预览(每拍一次付费调用),停止后照常出字 ──
3787
+ {
3788
+ const hostCatalogFixture = speechCatalog
3789
+ speechCatalog = { ...hostCatalogFixture, providers: [{ ...hostCatalogFixture.providers[0], location: 'cloud' }] }
3790
+ speechTranscriptQueue = [{ ok: true, text: '云端也能出字,只是没有预览' }]
3791
+ micButton.dispatch('click', { stopPropagation() {} })
3792
+ await waitVoice(() => hook.voice().phase === 'recording')
3793
+ assert('云端识别器:实时层不开(reason=cloud)', hook.voice().live.active === false && hook.voice().live.disabled === true && hook.voice().live.reason === 'cloud', JSON.stringify(hook.voice().live).slice(0, 120))
3794
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3795
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3796
+ assert('云端:停止后照常把文字插进输入框(退化成"说完再出字")', askBox.value.indexOf('云端也能出字,只是没有预览') >= 0, JSON.stringify(askBox.value))
3797
+ speechCatalog = hostCatalogFixture
3798
+ hook.askValue('')
3799
+ }
3800
+
3801
+ // ── ⑳ 回归:停顿那几拍"没听清"不能把实时层关掉(用户报的 bug)──────
3802
+ // 症状:"实时预览只跟第一句,后面就不吐字了"。
3803
+ // 原因:停顿里的预览窗口是纯静音 → 识别返回空转写(没听清)→ 被当成"失败"计数,
3804
+ // 连挂 3 次就把整个实时层 disable 了。
3805
+ {
3806
+ hook.askValue('')
3807
+ speechTranscriptQueue = [{ ok: true, text: '第一句' }]
3808
+ micButton.dispatch('click', { stopPropagation() {} })
3809
+ await waitVoice(() => hook.voice().phase === 'recording')
3810
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3811
+ const tap = context.processors[context.processors.length - 1]
3812
+ feedTap(tap, 1.2, 0.3)
3813
+ await waitVoice(() => hook.voice().live.preview === '第一句', 3000)
3814
+
3815
+ // 连续 5 拍都返回"没听清"(对应停顿里那些纯静音窗口)
3816
+ speechTranscriptRepeat = { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' }
3817
+ for (let i = 0; i < 5; i += 1) {
3818
+ feedTap(tap, 0.2, 0.3)
3819
+ hook.voiceTick()
3820
+ await sleep(150)
3821
+ }
3822
+ assert('空转写不算失败:连来 5 拍"没听清"也不关实时层', hook.voice().live.active === true && hook.voice().live.disabled === false, JSON.stringify({ active: hook.voice().live.active, disabled: hook.voice().live.disabled, reason: hook.voice().live.reason }))
3823
+ assert('空结果也不会把上一拍的预览抹掉(不闪没)', hook.voice().live.preview === '第一句' && askBox.value.indexOf('第一句') >= 0, JSON.stringify({ preview: hook.voice().live.preview, value: askBox.value }))
3824
+
3825
+ // 后面的话照样跟着出字(正面用例)
3826
+ speechTranscriptRepeat = { ok: true, text: '第二句照样跟' }
3827
+ feedTap(tap, 1.2, 0.3)
3828
+ await waitVoice(() => hook.voice().live.preview === '第二句照样跟', 3000)
3829
+ // 预览本来就是"这一拍窗口的识别结果",新文本会替换旧的;而且它随时可能被"停顿定稿"接手
3830
+ //(这里喂完就停手,600ms 后确实会定稿)。这条回归要的是:**实时层还活着、新的话确实进了输入框**。
3831
+ assert(
3832
+ '同一段录音里,后面的话照样跟着出字(实时层没死)',
3833
+ askBox.value.indexOf('第二句照样跟') >= 0 && hook.voice().live.disabled === false && hook.voice().live.reason === '',
3834
+ JSON.stringify({ preview: hook.voice().live.preview, committed: hook.voice().live.committed, value: askBox.value, reason: hook.voice().live.reason }),
3835
+ )
3836
+ speechTranscriptRepeat = null
3837
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3838
+ await sleep(30)
3839
+ hook.askValue('')
3840
+ }
3841
+
3842
+ // ── ㉑ 真故障连续 5 次才让位;静音窗口根本不发请求 ────────────────
3843
+ {
3844
+ speechTranscriptQueue = [{ ok: true, text: '先定稿一句' }]
3845
+ micButton.dispatch('click', { stopPropagation() {} })
3846
+ await waitVoice(() => hook.voice().phase === 'recording')
3847
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3848
+ const tap = context.processors[context.processors.length - 1]
3849
+ feedTap(tap, 1.2, 0.3)
3850
+ await waitVoice(() => hook.voice().live.passes.preview >= 1, 3000)
3851
+
3852
+ // 定稿之后一直是静音:窗口里没有新语音 → 一次调用都不该发
3853
+ speechTranscriptQueue = [{ ok: true, text: '定稿的那句' }]
3854
+ feedTap(tap, 0.9, 0)
3855
+ await waitVoice(() => hook.voice().live.committed !== '', 3000)
3856
+ const callsBefore = speechTranscribeCalls.length
3857
+ feedTap(tap, 1.0, 0)
3858
+ hook.voiceTick()
3859
+ await sleep(250)
3860
+ hook.voiceTick()
3861
+ await sleep(250)
3862
+ assert('定稿后一直静音:不再浪费识别调用(也避免把预览刷没)', speechTranscribeCalls.length === callsBefore && hook.voice().live.disabled === false, `${speechTranscribeCalls.length} vs ${callsBefore}`)
3863
+
3864
+ // 真故障(不是"没听清"):连续 5 次才让位
3865
+ speechTranscriptRepeat = { ok: false, code: 'failed', error: '识别服务挂了' }
3866
+ for (let i = 0; i < 6; i += 1) {
3867
+ feedTap(tap, 0.25, 0.3)
3868
+ hook.voiceTick()
3869
+ await sleep(180)
3870
+ }
3871
+ assert('真故障连续 5 次才让位(不让偶发一次超时把字幕关掉)', hook.voice().live.disabled === true && hook.voice().live.reason === 'preview-failed', JSON.stringify({ disabled: hook.voice().live.disabled, reason: hook.voice().live.reason }))
3872
+ speechTranscriptRepeat = null
3873
+ speechTranscriptQueue = [{ ok: true, text: '让位之后停止照样出字' }]
3874
+ nodes.stop.dispatch('click', { stopPropagation() {} })
3875
+ await waitVoice(() => hook.voice().phase === 'idle', 3000)
3876
+ assert('让位之后停止仍然出字(整段那条路不受影响)', askBox.value.indexOf('让位之后停止照样出字') >= 0, JSON.stringify(askBox.value))
3877
+ hook.askValue('')
3878
+ }
3879
+
3880
+ // ── ㉒ 输入框自适应拉伸(用户报:换行后下面的字看不见)────────────────
3881
+ // 桩里的 scrollHeight 是可控的:直接改它再触发一次刷新,就是"内容变高了"。
3882
+ {
3883
+ const box = askBox
3884
+ const originalScrollHeight = Object.getOwnPropertyDescriptor(box, 'scrollHeight')
3885
+ box.value = ''
3886
+ // 一行:高度=内容高度(和 CSS 的 min-height 一致),且不该出现滚动条
3887
+ box.scrollHeight = 26
3888
+ hook.askValue('一行')
3889
+ assert('输入框:一行内容时高度就是内容高(不出滚动条)', box.style.height === '26px' && box.style.overflowY === 'hidden', JSON.stringify({ height: box.style.height, overflow: box.style.overflowY }))
3890
+ box.scrollHeight = 78
3891
+ hook.askValue('第一行\n第二行\n第三行')
3892
+ assert('输入框:多行了就自己长高(高度=内容高)', box.style.height === '78px' && box.style.overflowY === 'hidden', JSON.stringify({ height: box.style.height, overflow: box.style.overflowY }))
3893
+
3894
+ box.scrollHeight = 400
3895
+ hook.askValue('很多行'.repeat(80))
3896
+ assert('输入框:到上限(132px)就停住、改成内部滚动', box.style.height === '132px' && box.style.overflowY === 'auto', JSON.stringify({ height: box.style.height, overflow: box.style.overflowY }))
3897
+ assert('输入框:超过上限时自动滚到最新一行(语音输入一直在末尾加字)', box.scrollTop === 400, String(box.scrollTop))
3898
+
3899
+ // 语音输入落字 → 也会走同一条自适应(liveWrite → refreshAskState)
3900
+ box.scrollHeight = 104
3901
+ hook.askValue('')
3902
+ speechTranscriptQueue = [{ ok: true, text: '这一句会比较长,长到换行以后还能看得见' }]
3903
+ micButton.dispatch('click', { stopPropagation() {} })
3904
+ await waitVoice(() => hook.voice().phase === 'recording')
3905
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3906
+ const tap = context.processors[context.processors.length - 1]
3907
+ feedTap(tap, 1.2, 0.3)
3908
+ box.scrollHeight = 104
3909
+ await waitVoice(() => hook.voice().live.preview !== '', 3000)
3910
+ assert('语音输入落字时输入框同样跟着长高', box.style.height === '104px', JSON.stringify({ height: box.style.height, value: box.value }))
3911
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
3912
+ await sleep(30)
3913
+
3914
+ // 恢复桩的 scrollHeight 语义(后面的用例还要用)
3915
+ box.scrollHeight = 0
3916
+ if (originalScrollHeight) Object.defineProperty(box, 'scrollHeight', originalScrollHeight)
3917
+ hook.askValue('')
3918
+ }
3919
+
3920
+ // ── ㉓ Esc 分两级:先取消语音,再关窗(+ 开窗聚焦输入框)────────────
3921
+ {
3922
+ // 开窗 → 输入框可见后自动聚焦一次(假 DOM 里 focus 是空实现,这里换成探针)
3923
+ let focused = 0
3924
+ const realFocus = askBox.focus
3925
+ askBox.focus = () => {
3926
+ focused += 1
3927
+ }
3928
+ hook.open('voice-probe', '', 'Esc 语义')
3929
+ await waitVoice(() => hook.state().phase === 'done', 3000)
3930
+ assert('开窗后自动聚焦输入框(键盘用户不用先 Tab)', focused === 1, String(focused))
3931
+
3932
+ // ① 语音进行中按 Esc:只取消这一次语音,面板留着
3933
+ speechTranscriptQueue = [{ ok: true, text: '按 Esc 之前已经在框里的字' }]
3934
+ micButton.dispatch('click', { stopPropagation() {} })
3935
+ await waitVoice(() => hook.voice().phase === 'recording')
3936
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
3937
+ const tap = context.processors[context.processors.length - 1]
3938
+ feedTap(tap, 1.2, 0.3)
3939
+ await waitVoice(() => hook.voice().live.preview !== '', 3000)
3940
+ const track = micTracks[micTracks.length - 1]
3941
+ documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
3942
+ await sleep(40)
3943
+ assert('语音中按 Esc:录音取消、麦克风松开', hook.voice().phase === 'idle' && track.stopped === true && hook.voice().capture === false, JSON.stringify({ phase: hook.voice().phase, stopped: track.stopped }))
3944
+ assert('语音中按 Esc:面板**留着**(不再顺手关掉整个小窗)', hook.quoteState().panelOpen === true && panel.style.display === 'flex', String(hook.quoteState().panelOpen))
3945
+ assert('语音中按 Esc:已经说出来的字留着(取消只停,不删字)', askBox.value.indexOf('按 Esc 之前已经在框里的字') >= 0, JSON.stringify(askBox.value))
3946
+
3947
+ // ② 不录音时再按 Esc:这次才关窗
3948
+ documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
3949
+ await sleep(40)
3950
+ assert('不录音时按 Esc:关窗(两级 Esc 的第二级)', hook.quoteState().panelOpen === false && panel.style.display === 'none', String(hook.quoteState().panelOpen))
3951
+
3952
+ // ③ 提示行还在(feedback)时按 Esc:先收起提示,面板不关
3953
+ micDenied = true
3954
+ hook.open('voice-probe', '', 'Esc 语义')
3955
+ await waitVoice(() => hook.state().phase === 'done', 3000)
3956
+ micButton.dispatch('click', { stopPropagation() {} })
3957
+ await waitVoice(() => hook.voice().phase === 'feedback')
3958
+ documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
3959
+ await sleep(40)
3960
+ assert('失败提示还在时按 Esc:收起提示、面板留着', hook.voice().phase === 'idle' && hook.quoteState().panelOpen === true, JSON.stringify({ phase: hook.voice().phase, open: hook.quoteState().panelOpen }))
3961
+ micDenied = false
3962
+ documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
3963
+ await sleep(40)
3964
+ assert('提示收掉之后再按 Esc 才关窗', hook.quoteState().panelOpen === false, String(hook.quoteState().panelOpen))
3965
+ askBox.focus = realFocus
3966
+ hook.askValue('')
3967
+ }
3968
+
3969
+ // ── ㉔ 开窗聚焦的两种情形 + 录音时光标跟着预览走 ────────────────
3970
+ {
3971
+ let focused = 0
3972
+ const realFocus = askBox.focus
3973
+ askBox.focus = () => {
3974
+ focused += 1
3975
+ }
3976
+ const realActive = documentStub.activeElement
3977
+
3978
+ // ① 焦点在浮标上 —— 这就是真实点开小窗时的情形(Chrome 点 <button> 会把焦点给它)
3979
+ hook.close()
3980
+ documentStub.activeElement = new FakeEl('button')
3981
+ hook.open('voice-probe', '', '聚焦')
3982
+ await waitVoice(() => hook.state().phase === 'done', 3000)
3983
+ assert('开窗聚焦:焦点在浮标按钮上时也要把光标交给输入框(真实路径)', focused === 1, String(focused))
3984
+
3985
+ // ② 人家正在别处(主会话输入框)打字 → 不抢
3986
+ hook.close()
3987
+ focused = 0
3988
+ documentStub.activeElement = new FakeEl('textarea')
3989
+ hook.open('voice-probe', '', '聚焦')
3990
+ await waitVoice(() => hook.state().phase === 'done', 3000)
3991
+ assert('开窗聚焦:别人正在别处打字就不抢(那才是真的别打扰)', focused === 0, String(focused))
3992
+
3993
+ // ③ 录音一开始就把光标交给输入框;预览落字后光标停在最新文字后面
3994
+ hook.close()
3995
+ documentStub.activeElement = realActive
3996
+ focused = 0
3997
+ hook.askValue('')
3998
+ speechTranscriptQueue = [{ ok: true, text: '第一拍预览' }]
3999
+ micButton.dispatch('click', { stopPropagation() {} })
4000
+ await waitVoice(() => hook.voice().phase === 'recording')
4001
+ assert('录音开始就把光标交给输入框(不聚焦就看不见光标跳)', focused >= 1, String(focused))
4002
+ const context = AudioContextStub.instances[AudioContextStub.instances.length - 1]
4003
+ const tap = context.processors[context.processors.length - 1]
4004
+ feedTap(tap, 1.2, 0.3)
4005
+ await waitVoice(() => hook.voice().live.preview === '第一拍预览', 3000)
4006
+ assert('预览落字后:光标停在最新文字末尾(会在那里跳)', askBox.selectionStart === askBox.value.length && askBox.value.length > 0, `${askBox.selectionStart}/${askBox.value.length}`)
4007
+
4008
+ // ④ 预览换成更长的一句 → 光标跟着新末尾走
4009
+ speechTranscriptRepeat = { ok: true, text: '第二拍预览更长一些' }
4010
+ feedTap(tap, 1.2, 0.3)
4011
+ hook.voiceTick()
4012
+ await waitVoice(() => hook.voice().live.preview === '第二拍预览更长一些', 3000)
4013
+ assert('预览整段替换时:光标跟到新末尾(不会留在旧位置)', askBox.selectionStart === askBox.value.length, `${askBox.selectionStart}/${askBox.value.length}`)
4014
+
4015
+ // ⑤ 用户自己把光标挪走 → 不再跟着挪(不抢他的位置)
4016
+ speechTranscriptRepeat = { ok: true, text: '第三拍再长一点的预览文字' }
4017
+ askBox.setSelectionRange(0, 0)
4018
+ feedTap(tap, 1.2, 0.3)
4019
+ hook.voiceTick()
4020
+ await sleep(250)
4021
+ assert('用户自己挪了光标之后就不再跟着挪(不跟他抢位置)', askBox.selectionStart === 0, String(askBox.selectionStart))
4022
+
4023
+ speechTranscriptRepeat = null
4024
+ nodes.cancel.dispatch('click', { stopPropagation() {} })
4025
+ await sleep(40)
4026
+ askBox.focus = realFocus
4027
+ documentStub.activeElement = realActive
4028
+ hook.askValue('')
4029
+ }
4030
+
4031
+ // ── ⑫ 音频格式:和 host 的 validateWave 同源(纯函数打表)────────
4032
+ const helpers = new Function(
4033
+ [
4034
+ sliceFunction(source, 'encodeWave'),
4035
+ sliceFunction(source, 'bytesToBase64'),
4036
+ sliceFunction(source, 'formatClock'),
4037
+ 'return { encodeWave: encodeWave, bytesToBase64: bytesToBase64, formatClock: formatClock }',
4038
+ ].join('\n'),
4039
+ )()
4040
+ const samples = new Float32Array(16000) // 1 秒
4041
+ for (let i = 0; i < samples.length; i += 1) samples[i] = Math.sin(i / 12) * 0.7
4042
+ const wave = helpers.encodeWave(samples)
4043
+ const view = new DataView(wave.buffer)
4044
+ const ascii = (start, end) => Buffer.from(wave.slice(start, end)).toString('ascii')
4045
+ assert('WAV 头:RIFF/WAVE/fmt', ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE' && ascii(12, 16) === 'fmt ', ascii(0, 16))
4046
+ assert(
4047
+ 'WAV 头:16kHz / 单声道 / PCM16(host 逐字段校验的就是这几个)',
4048
+ view.getUint32(24, true) === 16000 && view.getUint16(22, true) === 1 && view.getUint16(34, true) === 16 && view.getUint16(20, true) === 1,
4049
+ `${view.getUint32(24, true)}Hz ch=${view.getUint16(22, true)} bit=${view.getUint16(34, true)}`,
4050
+ )
4051
+ assert('WAV 头:长度字段自洽(RIFF / data 都算对)', view.getUint32(4, true) === wave.length - 8 && view.getUint32(40, true) === wave.length - 44, `${view.getUint32(4, true)} / ${view.getUint32(40, true)}`)
4052
+ assert('WAV:1 秒 = 44 字节头 + 32000 字节数据', wave.length === 44 + 32000, String(wave.length))
4053
+ assert('base64 是可解回来的(host 会校验"规范 base64")', Buffer.from(helpers.bytesToBase64(wave), 'base64').equals(Buffer.from(wave)), 'round-trip')
4054
+ const clipped = helpers.encodeWave(new Float32Array([2, -2, 0]))
4055
+ // 16 位小端、有符号:+32767 = ff 7f,-32768 = 00 80(不夹的话 2*32767 会绕成负数)
4056
+ assert(
4057
+ '采样越界会被夹住(±2 不该绕回成爆音)',
4058
+ clipped[44] === 0xff && clipped[45] === 0x7f && clipped[46] === 0x00 && clipped[47] === 0x80,
4059
+ `${clipped[44]},${clipped[45]},${clipped[46]},${clipped[47]}`,
4060
+ )
4061
+ assert('录音秒数显示成 0:07 这种', helpers.formatClock(7500) === '0:07' && helpers.formatClock(60000) === '1:00', helpers.formatClock(7500))
4062
+
4063
+ hook.close()
4064
+ }
4065
+
2506
4066
  // ───────────────────────── 关闭 / 清理 ─────────────────────────
2507
4067
  hook.close()
2508
4068
  assert('Esc/关闭后隐藏', panel.style.display === 'none')
@@ -2514,6 +4074,7 @@ for (const dispose of disposers.reverse()) {
2514
4074
  }
2515
4075
  }
2516
4076
  assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
4077
+ assert('清理后划词桥自己建的预览 blob 被回收', bridgeOwnedBlob !== '' && !blobStore.has(bridgeOwnedBlob), bridgeOwnedBlob)
2517
4078
  assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
2518
4079
  console.log('\n=== 客户端集成测试结束 ===')
2519
4080