@yfwu2020/dsh-selection-explain 0.2.0 → 0.4.1

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.
@@ -30,6 +30,33 @@ window.__ModuleLoader__.load({
30
30
  var HISTORY = '/selection-explain/api/history'
31
31
  /** 模型清单(带每个模型支持的推理等级)。 */
32
32
  var MODELS = '/selection-explain/api/models'
33
+ /**
34
+ * 语音输入(小窗麦克风):识别在 host 上做,浏览器只负责录一段规范 WAV 送过去。
35
+ * · 目录接口说清"能不能录、要录多久、要不要先准备模型";
36
+ * · 音频不落盘、不进会话,转写完即丢(客户端的引用里也不会出现音频)。
37
+ */
38
+ var SPEECH_API = '/selection-explain/api/speech'
39
+ var SPEECH_TRANSCRIBE = SPEECH_API + '/transcribe'
40
+ var SPEECH_PREPARE = SPEECH_API + '/prepare'
41
+ /** 兜底限制(host 的 limits 优先;拿不到目录时按这套走)。 */
42
+ var VOICE_FALLBACK_SECONDS = 60
43
+ var VOICE_MAX_BYTES = 4 * 1024 * 1024
44
+ /** 目录缓存时长:同一分钟内反复点麦克风不再重复问 host。 */
45
+ var VOICE_CATALOG_TTL = 60 * 1000
46
+ /**
47
+ * 实时字幕(半句预览 + 停顿定稿):
48
+ * · 说话时每 ~1.3s 把"还没定稿的这半句"送去识别一次,结果**整段替换**上一拍的预览;
49
+ * · 检测到 ~0.6s 静音(一句话说完了)就把这句送去识别并**定稿**,之后不再变。
50
+ * 只在本地识别器上默认开(云端每拍一次都是付费调用);窗口固定 ≤10 秒,
51
+ * 这样说到第 40 秒也不会变慢(实测 8s 窗口 185ms、40s 整段要 1.1s)。
52
+ */
53
+ var VOICE_PREVIEW_EVERY = 1300
54
+ var VOICE_PREVIEW_WINDOW = 10
55
+ var VOICE_PAUSE_MS = 600
56
+ var VOICE_PAUSE_MIN_MS = 400
57
+ var VOICE_COMMIT_MAX_SECONDS = 25
58
+ /** "有声"的门限(RMS):低于它算静音,连着 0.6s 就是一句话说完了。 */
59
+ var VOICE_SILENCE_RMS = 0.012
33
60
  /** 选区长度上限(与 host 默认值一致,host 还会再校验一次)。 */
34
61
  var MAX_SELECTION = 4000
35
62
  /** 上下文窗口:选区前后各取多少字符。 */
@@ -37,6 +64,37 @@ window.__ModuleLoader__.load({
37
64
 
38
65
  /** key 只取选中文字**之前**这么多字:后缀会随新消息变化,不能进 key。 */
39
66
  var KEY_CONTEXT_CHARS = 300
67
+ /**
68
+ * 引用(❝):小窗开着时,把**小窗里选中的正文**或**主界面上选中的文字**
69
+ * 挂进小窗输入框,发送时作为材料一并发给模型。
70
+ * · 一次最多几段 —— 每段都会进本轮提问,堆太多等于把问题埋掉;
71
+ * · 每段字数上限 —— 超了截断(引用整条回答时最长的那种会撞上)。
72
+ */
73
+ var MAX_QUOTES = 4
74
+ var MAX_QUOTE_CHARS = 3000
75
+ /**
76
+ * 引用**选区**的长度上限(比 MAX_QUOTE_CHARS 宽得多)。
77
+ *
78
+ * 划一大段(比如整条回答)时也要让浮标出来 —— 出来之后按 MAX_QUOTE_CHARS 截断并说明;
79
+ * 早先这里直接用 MAX_QUOTE_CHARS 判断,结果是"划长一点就没反应",
80
+ * 而用户根本不知道为什么(浮标不出现 = 没有任何反馈)。
81
+ */
82
+ var MAX_QUOTE_SELECTION = 20000
83
+ /** 只挂了引用、一个字都没写时,用它当提问(不替用户编复杂问题)。 */
84
+ var QUOTE_ONLY_QUESTION = '就上面引用的文字,说说它在这里是什么意思。'
85
+ /**
86
+ * 引用的上下文:**只带模型看不到的东西**。
87
+ * · 小窗自己的正文(回答 / 两节 / 顶部原文条)→ **不带**:那几轮对话本来就会随 history
88
+ * 进模型,出处靠标签说清(「小窗第 2 轮回答」「小窗「翻译」节」);
89
+ * · 主界面(会话)→ 由 host 从会话记录里取"引用所在那一组对话 ± 一组"(干净文本、按轮取整),
90
+ * 见 QUOTE_CONTEXT_API;客户端先用局部窗口兜底,发送前才去问 host;
91
+ * · 侧边栏网页 → 帧内桥采的局部窗口。
92
+ */
93
+ var QUOTE_CONTEXT_API = '/selection-explain/api/quote-context'
94
+ /** 会话版上下文的等待上限(毫秒):超了就用客户端那份,不让发送卡住。 */
95
+ var QUOTE_CONTEXT_TIMEOUT = 1500
96
+ /** 会话版上下文的等待上限(毫秒):超了就用客户端那份,不让发送卡住。 */
97
+ var QUOTE_CONTEXT_TIMEOUT = 1500
40
98
  /** 面板 z-index 顶部。 */
41
99
  var Z_BTN = 2147483000
42
100
  var Z_PANEL = 2147483001
@@ -75,6 +133,74 @@ window.__ModuleLoader__.load({
75
133
  return ms < 1000 ? ms + 'ms' : (ms / 1000).toFixed(1) + 's'
76
134
  }
77
135
 
136
+ // ────────────────────────── 录音(语音输入)──────────────────────────
137
+ // 这一节是**纯函数 + 浏览器能力判断**,不碰面板状态:小窗的编排在 apply() 里。
138
+ // 单位换算和 WAV 头必须和 host 的 validateWave 逐字段对上(16kHz / 单声道 / PCM16),
139
+ // 对不上 host 会直接拒收 —— 这是刻意的:宁可明确报"音频格式不对",
140
+ // 也不要让识别服务按错误的采样率去听一段快放。
141
+
142
+ /** 录音时长:0:07(上限 60 秒,所以只有分:秒)。 */
143
+ function formatClock(ms) {
144
+ var seconds = Math.max(0, Math.floor(ms / 1000))
145
+ var mm = Math.floor(seconds / 60)
146
+ var ss = seconds % 60
147
+ return mm + ':' + (ss < 10 ? '0' : '') + ss
148
+ }
149
+
150
+ /**
151
+ * Float32 采样(16kHz 单声道)→ 规范的 PCM16 WAV。
152
+ * @param samples 采样(-1..1)
153
+ * @returns Uint8Array(44 字节头 + 数据)
154
+ */
155
+ function encodeWave(samples) {
156
+ var bytes = new Uint8Array(44 + samples.length * 2)
157
+ var view = new DataView(bytes.buffer)
158
+ function ascii(offset, text) {
159
+ for (var i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i))
160
+ }
161
+ ascii(0, 'RIFF')
162
+ view.setUint32(4, 36 + samples.length * 2, true)
163
+ ascii(8, 'WAVE')
164
+ ascii(12, 'fmt ')
165
+ view.setUint32(16, 16, true) // fmt 块长度
166
+ view.setUint16(20, 1, true) // PCM
167
+ view.setUint16(22, 1, true) // 单声道
168
+ view.setUint32(24, 16000, true) // 采样率
169
+ view.setUint32(28, 32000, true) // 字节率
170
+ view.setUint16(32, 2, true) // 块对齐
171
+ view.setUint16(34, 16, true) // 位深
172
+ ascii(36, 'data')
173
+ view.setUint32(40, samples.length * 2, true)
174
+ for (var i = 0; i < samples.length; i += 1) {
175
+ // 先夹到 [-1,1]:重采样后的浮点偶尔会略微越界,直接乘会绕回成刺耳的爆音
176
+ var value = samples[i] < -1 ? -1 : samples[i] > 1 ? 1 : samples[i]
177
+ view.setInt16(44 + i * 2, Math.round(value * (value < 0 ? 32768 : 32767)), true)
178
+ }
179
+ return bytes
180
+ }
181
+
182
+ /** 字节 → base64(分块喂 fromCharCode,几十万采样一次性展开会爆栈)。 */
183
+ function bytesToBase64(bytes) {
184
+ var text = ''
185
+ for (var i = 0; i < bytes.length; i += 8192) {
186
+ text += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192))
187
+ }
188
+ return btoa(text)
189
+ }
190
+
191
+ /** 这个浏览器能不能录音(不支持时麦克风按钮直接给解释,而不是点了才报错)。 */
192
+ function recordingSupported() {
193
+ try {
194
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices) return false
195
+ if (typeof navigator.mediaDevices.getUserMedia !== 'function') return false
196
+ // 全部走 window.*:浏览器里 window 就是全局对象,测试里也能注入同一套桩
197
+ if (typeof window.MediaRecorder === 'undefined') return false
198
+ return typeof window.AudioContext !== 'undefined' || typeof window.webkitAudioContext !== 'undefined'
199
+ } catch (error) {
200
+ return false
201
+ }
202
+ }
203
+
78
204
  // ────────────────────────────── 样式 ──────────────────────────────
79
205
 
80
206
  var CSS = [
@@ -272,10 +398,45 @@ window.__ModuleLoader__.load({
272
398
  'border-radius:22px;border:1px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));',
273
399
  'background:var(--dsw-alias-bg-base,transparent)}',
274
400
  '.dsh-sel-ask:focus-within{border-color:var(--sel-a1,#0d9488)}',
275
- '.dsh-sel-askbox{width:100%;min-height:26px;max-height:96px;overflow-y:auto;resize:none;padding:4px 0 0;',
401
+ // 高度由 JS 跟着内容长(autoGrowAskBox):textarea 自己只会缩在 rows=1 的高度里,
402
+ // 换行之后下面的字就被裁掉了(语音输入时最明显 —— 字是自己长出来的,用户根本没在敲键盘)。
403
+ // 这里给个上限:过了就内部滚动,面板不会被一段长文顶穿。
404
+ '.dsh-sel-askbox{width:100%;min-height:26px;max-height:132px;overflow-y:hidden;resize:none;padding:4px 0 0;',
276
405
  'border:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:1.5;outline:none}',
406
+ // 引用区(composer 第一行之上):每段一张小卡片,来源一行小字 + 内容一行(超出省略)+ 右侧 ✕。
407
+ // 卡片是**待发送**的引用,发送后整片清空 —— 它属于"这条消息",不属于会话。
408
+ '.dsh-sel-quotes{display:none;flex-direction:column;gap:4px;max-height:96px;overflow-y:auto;',
409
+ 'scrollbar-width:thin;padding:1px 0 0}',
410
+ '.dsh-sel-quotes[data-show="1"]{display:flex}',
411
+ '.dsh-sel-quotechip{display:flex;align-items:center;gap:7px;padding:4px 4px 4px 8px;border-radius:10px;',
412
+ 'border-left:2px solid color-mix(in srgb,var(--sel-a1,#0d9488) 55%,transparent);',
413
+ 'background:color-mix(in srgb,var(--sel-a1,#0d9488) 7%,transparent)}',
414
+ '.dsh-sel-quotechip-src{flex:0 0 auto;font-size:10.5px;line-height:1.5;opacity:.66;white-space:nowrap;',
415
+ 'max-width:96px;overflow:hidden;text-overflow:ellipsis}',
416
+ '.dsh-sel-quotechip-text{flex:1 1 auto;min-width:0;font-size:12px;line-height:1.5;opacity:.9;',
417
+ 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
418
+ '.dsh-sel-quotechip-x{flex:0 0 auto;width:18px;height:18px;padding:0;border:0;border-radius:9px;cursor:pointer;',
419
+ 'background:transparent;color:var(--dsw-alias-label-secondary,inherit);opacity:.5;font:inherit;font-size:11px;',
420
+ 'line-height:1;display:inline-flex;align-items:center;justify-content:center}',
421
+ '.dsh-sel-quotechip-x:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.16))}',
422
+ // 气泡里的引用块(用户消息):引用在上、问题在下 —— 和发出去给模型的那份顺序一致
423
+ '.dsh-sel-bq{display:flex;flex-direction:column;gap:3px;margin-bottom:6px}',
424
+ '.dsh-sel-bqitem{padding:3px 8px;border-radius:8px;border-left:2px solid color-mix(in srgb,var(--sel-a1,#0d9488) 45%,transparent);',
425
+ 'background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.14));font-size:11.5px;line-height:1.55;',
426
+ 'opacity:.82;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
427
+ '.dsh-sel-bqitem b{font-weight:600;opacity:.75;margin-right:5px}',
428
+ // 「引用整条」:贴在助手气泡末尾,**平时不出现**(正文优先),鼠标移到这条消息上才浮出来。
429
+ // 键盘 Tab 也能到(:focus-visible 同样点亮),触屏上另有浮标那条路。
430
+ '.dsh-sel-bubquote{margin-top:7px;display:inline-flex;align-items:center;gap:4px;padding:2px 9px;',
431
+ 'border:1px solid transparent;border-radius:9px;background:transparent;cursor:pointer;',
432
+ 'color:var(--dsw-alias-label-secondary,inherit);font:inherit;font-size:11px;line-height:1.6;',
433
+ 'opacity:0;transition:opacity .15s ease}',
434
+ '.dsh-sel-bubble:hover .dsh-sel-bubquote,.dsh-sel-bubquote:focus-visible{opacity:.66}',
435
+ '.dsh-sel-bubquote:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.14))}',
277
436
  // 下面那行:左侧网页模式(圆形浅底,和主会话的 + 同款),右侧发送(圆形实心)
278
- '.dsh-sel-asktools{display:flex;align-items:center;gap:8px}',
437
+ // flex-wrap 是给窄屏兜底的:这一行现在有 4 件东西(输出偏好 / 模型 / 麦克风 / 发送),
438
+ // 窄面板下宁可折成两行,也不能把发送键挤出可视区(挤出去就点不到了)。
439
+ '.dsh-sel-asktools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;row-gap:6px}',
279
440
  '.dsh-sel-askspace{flex:1 1 auto}',
280
441
  // 页眉里的撑开占位(标题与右侧按钮之间)
281
442
  '.dsh-sel-headspace{flex:1 1 auto;min-width:8px}',
@@ -338,7 +499,44 @@ window.__ModuleLoader__.load({
338
499
  // 生成中:发送键变成"停止"(方形图标,点了打断当前输出)
339
500
  '.dsh-sel-asksend[data-mode="stop"]{background:var(--dsw-alias-label-secondary,rgba(120,120,120,.9));opacity:1}',
340
501
  '.dsh-sel-asksend[data-mode="stop"]:hover{filter:brightness(1.12)}',
341
- // 页脚整条删掉了:状态改成**浮在输入框上方的短提示**,2.6 秒后自己淡出(不占版面)
502
+ // ── 语音输入(麦克风)──
503
+ // **和主会话同一套交互**(官方 ui-voice-input):平时是输入框右边的 🎤;
504
+ // 一旦开始录,工具行整个换成"录音行" ——`✕` | 实时波形 | `■`(识别中则是
505
+ // 「识别中…」+ 呼吸点),发送键原位不动。尺寸按小窗的 composer 收成 26px
506
+ // (主会话是 32px),其余结构、图标语义、波形算法都照抄,两处看起来才是同一个东西。
507
+ '.dsh-sel-mic{transition:background .15s ease,color .15s ease}',
508
+ '@keyframes dsh-sel-spin{to{transform:rotate(360deg)}}',
509
+ '.dsh-sel-mic[data-state="requesting"] svg{animation:dsh-sel-spin .9s linear infinite}',
510
+ '.dsh-sel-mic:disabled{cursor:default;opacity:.35}',
511
+ // 录音行:flex:1 让它吃掉中间那截(主会话也是 waveform flex:1)
512
+ '.dsh-sel-capture{display:none;flex:1 1 auto;min-width:0;align-items:center;gap:8px}',
513
+ '.dsh-sel-capture[data-show="1"]{display:flex}',
514
+ // 圆键:和发送键同尺寸、同圆度;浅底 + hover 变深(主会话的 roundButton 也是这套)
515
+ '.dsh-sel-round{flex:0 0 auto;width:26px;height:26px;padding:0;border:0;border-radius:13px;cursor:pointer;',
516
+ 'display:inline-grid;place-items:center;background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.14));',
517
+ 'color:var(--dsw-alias-label-secondary,inherit)}',
518
+ '.dsh-sel-round:hover{background:var(--dsw-alias-interactive-bg-hover-solid,rgba(140,140,140,.28));',
519
+ 'color:var(--dsw-alias-label-primary,inherit)}',
520
+ '.dsh-sel-round:disabled{cursor:default;opacity:.35}',
521
+ '.dsh-sel-round svg{width:14px;height:14px;display:block}',
522
+ // 停止键:图标是实心方块(主会话的 IconStopFill),颜色取主文字色 —— 一眼是"停"
523
+ '.dsh-sel-stop{color:var(--dsw-alias-label-primary,inherit)}',
524
+ // 波形:80 根竖线、中间对齐、静音时是一条虚线(算法与主会话逐行一致,见 paintWave)
525
+ '.dsh-sel-vwave{flex:1 1 auto;min-width:24px;height:24px;display:block;color:var(--dsw-alias-label-secondary,inherit)}',
526
+ // 状态文案(请允许使用麦克风… / 识别中… / 没听清…):12px 次要色 + 可选呼吸点
527
+ '.dsh-sel-vactivity{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.4;',
528
+ 'color:var(--dsw-alias-label-secondary,inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
529
+ '.dsh-sel-vdot{flex:0 0 auto;width:6px;height:6px;border-radius:50%;background:var(--sel-a1,#0d9488);',
530
+ 'animation:dsh-sel-pillpulse 1.4s ease-in-out infinite}',
531
+ '.dsh-sel-vactivity[data-tone="error"]{color:var(--dsw-alias-state-error-primary,#e5484d)}',
532
+ '.dsh-sel-vactivity[data-tone="warn"]{color:var(--dsw-alias-state-warn-primary,#b45309)}',
533
+ '.dsh-sel-vactslot{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px}',
534
+ '.dsh-sel-vactslot:empty{display:none}',
535
+ // 行内动作(准备模型 / 重新录音):主会话是同款的 inlineAction
536
+ '.dsh-sel-vact{flex:0 0 auto;height:22px;padding:0 9px;border:1px solid currentColor;border-radius:11px;cursor:pointer;',
537
+ 'background:transparent;color:inherit;font:inherit;font-size:11px;line-height:1;opacity:.85;white-space:nowrap}',
538
+ '.dsh-sel-vact:hover{opacity:1}',
539
+ '.dsh-sel-vact:disabled{opacity:.4;cursor:default}',
342
540
  '.dsh-sel-askbox:focus{border-color:var(--sel-a1,#0d9488)}',
343
541
  // 内容区里的「重新生成」:只在没有可用结果(失败 / 已停止 / 空回答)时出现
344
542
  '.dsh-sel-retry{margin-top:8px;border:1px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));background:transparent;',
@@ -1685,6 +1883,15 @@ window.__ModuleLoader__.load({
1685
1883
  stage: '',
1686
1884
  /** 追问轮次:[{ role: 'user' | 'assistant', text }],只活在面板/内存里(临时)。 */
1687
1885
  turns: [],
1886
+ /**
1887
+ * 待发送的引用:[{ label: '小窗回答' | '主界面选中' …, text }]。
1888
+ * 只属于**下一条**提问(发送即清空);关掉面板不清 —— 回来接着写还看得见。
1889
+ */
1890
+ quotes: [],
1891
+ /** 最近一次"可以引用"的选区:[{ text, label, rect, source }](浮标据此显示)。 */
1892
+ quoteSelection: null,
1893
+ /** 正在取引用的会话上下文(这期间再按 Enter 不能空发一条)。 */
1894
+ resolvingQuotes: false,
1688
1895
  /** 最近一条状态文本(界面上不显示,只给自检用)。 */
1689
1896
  lastStatus: '',
1690
1897
  /** 是不是"跟着最新消息走"(发完消息自动置真;用户自己往上滚就交回给他)。 */
@@ -1737,6 +1944,25 @@ window.__ModuleLoader__.load({
1737
1944
  button.appendChild(el('span', null, '解读'))
1738
1945
  layer.appendChild(button)
1739
1946
 
1947
+ /**
1948
+ * 引用浮标(`❝ 引用`):**小窗开着**的时候才有它 —— 这时候用户划词多半是想
1949
+ * 接着问,而不是再开一个小窗(再开一个的入口是先把当前小窗收起来)。
1950
+ * 两种来源共用它:
1951
+ * · 小窗自己正文里的选区(回答 / 翻译 / 详解 / 顶部选中文字条);
1952
+ * · 小窗开着时主界面上的选区(含侧边栏网页里那条由桥报上来的)。
1953
+ * 点一下 → 挂进小窗输入框的引用区,不打断输入。
1954
+ */
1955
+ var quoteButton = el('button', 'dsh-sel-btn dsh-sel-quotebtn')
1956
+ quoteButton.type = 'button'
1957
+ quoteButton.title = '引用到小窗输入框(作为下一句提问的材料)'
1958
+ // **必须比面板高**:小窗里划词时,浮标是画在面板上面的那层(同一个浮层里的兄弟节点,
1959
+ // 谁 z-index 大谁在上)。用 Z_BTN 的话浮标会被面板整个盖住 —— 看不见也点不着。
1960
+ quoteButton.style.zIndex = String(Z_PANEL + 1)
1961
+ quoteButton.style.pointerEvents = 'auto'
1962
+ quoteButton.appendChild(quoteIcon())
1963
+ quoteButton.appendChild(el('span', null, '引用'))
1964
+ layer.appendChild(quoteButton)
1965
+
1740
1966
  /**
1741
1967
  * 悬浮状态胶囊:显示"最近一次划词现在处于什么状态",点一下回到那个小窗。
1742
1968
  * 位置由 placePill() 动态贴到右下角费用胶囊(dsh-spend)上方;老版本没装那个插件时用兜底位置。
@@ -1794,6 +2020,10 @@ window.__ModuleLoader__.load({
1794
2020
  var chatLog = el('div', 'dsh-sel-chatlog')
1795
2021
  body.appendChild(chatLog)
1796
2022
  var askRow = el('div', 'dsh-sel-ask')
2023
+ // 引用区是 composer 的**第一行**:挂在输入框上面(发送时随提问一起带走、发完清空)。
2024
+ // 顺序很重要 —— 先 append 到 askRow 再 append 输入框,否则会排到工具行下面去。
2025
+ var quotesBox = el('div', 'dsh-sel-quotes')
2026
+ askRow.appendChild(quotesBox)
1797
2027
  var askBox = el('textarea', 'dsh-sel-askbox')
1798
2028
  askBox.rows = 1
1799
2029
  askBox.placeholder = '就这段文字继续追问…(Enter 发送,Shift+Enter 换行)'
@@ -1823,6 +2053,14 @@ window.__ModuleLoader__.load({
1823
2053
  askSend.type = 'button'
1824
2054
  askSend.setAttribute('aria-label', '发送')
1825
2055
  askSend.appendChild(sendIcon())
2056
+ // 语音输入:麦克风贴在发送键左边(和主会话 composer 的位置一致)。
2057
+ // 平时就是这枚图标;一旦开始录,工具行整个换成下面的"录音行"(与主会话同一套)。
2058
+ var micButton = el('button', 'dsh-sel-iconbtn dsh-sel-mic')
2059
+ micButton.type = 'button'
2060
+ micButton.setAttribute('data-state', 'idle')
2061
+ micButton.setAttribute('aria-label', '语音输入')
2062
+ micButton.title = '语音输入(点一下开始说,再点一下结束并转成文字)'
2063
+ micButton.appendChild(micIcon())
1826
2064
  // 模型 + 推理等级(追问档):胶囊在发送键左边,菜单向上弹
1827
2065
  var modelPill = el('button', 'dsh-sel-picker')
1828
2066
  modelPill.type = 'button'
@@ -1836,14 +2074,71 @@ window.__ModuleLoader__.load({
1836
2074
  var modelMenu = el('div', 'dsh-sel-pickermenu')
1837
2075
  modelMenu.setAttribute('role', 'menu')
1838
2076
 
2077
+ // ── 录音行(与主会话 ui-voice-input 同一套结构)──
2078
+ // [✕ 取消] [实时波形 / 状态文案] [■ 停止 或 行内动作] …… [↑ 发送(原位不动)]
2079
+ // 录音时是「✕ + 波形 + ■」;请求权限与识别中是「✕ + 呼吸点 + 请允许使用麦克风…/识别中…」;
2080
+ // 出错是「✕ + 原因 + 行内动作(准备模型 / 重新录音)」。
2081
+ var captureRow = el('div', 'dsh-sel-capture')
2082
+ captureRow.setAttribute('data-show', '0')
2083
+ var voiceCancel = el('button', 'dsh-sel-round')
2084
+ voiceCancel.type = 'button'
2085
+ voiceCancel.setAttribute('aria-label', '取消')
2086
+ voiceCancel.title = '取消(Esc)'
2087
+ voiceCancel.appendChild(closeIcon())
2088
+ // 波形:80 根竖线,静音时是一条虚线(算法照抄主会话的 Waveform)
2089
+ var waveSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
2090
+ waveSvg.setAttribute('class', 'dsh-sel-vwave')
2091
+ waveSvg.setAttribute('viewBox', '0 0 640 40')
2092
+ waveSvg.setAttribute('preserveAspectRatio', 'none')
2093
+ waveSvg.setAttribute('role', 'img')
2094
+ waveSvg.setAttribute('aria-label', '正在录音…')
2095
+ var waveBars = []
2096
+ for (var waveIndex = 0; waveIndex < 80; waveIndex += 1) {
2097
+ var waveBar = document.createElementNS('http://www.w3.org/2000/svg', 'line')
2098
+ waveBar.setAttribute('x1', String(waveIndex * 8 + 4))
2099
+ waveBar.setAttribute('x2', String(waveIndex * 8 + 4))
2100
+ waveBar.setAttribute('y1', '19')
2101
+ waveBar.setAttribute('y2', '21')
2102
+ waveBar.setAttribute('stroke', 'currentColor')
2103
+ waveBar.setAttribute('stroke-width', '3')
2104
+ waveBar.setAttribute('stroke-linecap', 'round')
2105
+ waveBar.setAttribute('opacity', String(0.25 + waveIndex / 120))
2106
+ waveSvg.appendChild(waveBar)
2107
+ // 最新的电平在最右边:和主会话一样从右往左推
2108
+ waveBars.unshift({ node: waveBar, level: 0 })
2109
+ }
2110
+ var voiceActivity = el('span', 'dsh-sel-vactivity')
2111
+ voiceActivity.setAttribute('role', 'status')
2112
+ var voiceDot = el('span', 'dsh-sel-vdot')
2113
+ var voiceActivityText = el('span', null, '')
2114
+ voiceActivity.appendChild(voiceDot)
2115
+ voiceActivity.appendChild(voiceActivityText)
2116
+ var voiceStop = el('button', 'dsh-sel-round dsh-sel-stop')
2117
+ voiceStop.type = 'button'
2118
+ voiceStop.setAttribute('aria-label', '停止并识别')
2119
+ voiceStop.title = '停止并识别'
2120
+ voiceStop.appendChild(stopIcon())
2121
+ // 行内动作位:按状态放「准备模型」文字键或「重新录音」🎤圆键(主会话同款 inlineAction)
2122
+ var voiceActionSlot = el('span', 'dsh-sel-vactslot')
2123
+ captureRow.appendChild(voiceCancel)
2124
+ captureRow.appendChild(waveSvg)
2125
+ captureRow.appendChild(voiceActivity)
2126
+ captureRow.appendChild(voiceActionSlot)
2127
+ captureRow.appendChild(voiceStop)
2128
+
1839
2129
  var askTools = el('div', 'dsh-sel-asktools')
2130
+ var askSpacer = el('span', 'dsh-sel-askspace')
1840
2131
  askTools.appendChild(webMode)
1841
- askTools.appendChild(el('span', 'dsh-sel-askspace'))
2132
+ askTools.appendChild(askSpacer)
1842
2133
  askTools.appendChild(modelPill)
2134
+ askTools.appendChild(micButton)
2135
+ askTools.appendChild(captureRow)
1843
2136
  askTools.appendChild(askSend)
1844
2137
  askRow.appendChild(askBox)
1845
2138
  askRow.appendChild(askTools)
1846
2139
  askRow.appendChild(modelMenu)
2140
+ // 引用区先画一次(空态:data-show="0" 收起),别等第一次 addQuote 才建立初始状态
2141
+ renderQuotes()
1847
2142
  // 「重新生成」不再常驻页脚:只在内容区没有可用结果时出现在正文里
1848
2143
  var retryButton = el('button', 'dsh-sel-retry', '重新生成')
1849
2144
  retryButton.type = 'button'
@@ -1862,6 +2157,8 @@ window.__ModuleLoader__.load({
1862
2157
  * React 组件只负责把 layer 容器挂进浮层;浮标/面板由上面的命令式代码管理,
1863
2158
  * React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
1864
2159
  */
2160
+ /** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
2161
+ var askFocusPending = false
1865
2162
  var offSlot = null
1866
2163
  if (ctx.slots && typeof ctx.slots.inject === 'function') {
1867
2164
  try {
@@ -1939,8 +2236,143 @@ window.__ModuleLoader__.load({
1939
2236
  return false
1940
2237
  }
1941
2238
 
2239
+ /**
2240
+ * 这一段选区是不是**小窗正文**里的(可以引用)。
2241
+ *
2242
+ * 往上走到小窗的滚动内容区(body)为止:中途碰到输入框 / 按钮 / 引用卡片本身
2243
+ * 就不算 —— 那些地方的选区要么是输入(textarea),要么引用了也没意义("✕"这种按钮文字)。
2244
+ * 历史列表、胶囊、模型菜单都在 panel/body 之外,自然被挡掉。
2245
+ * 不用 closest():无浏览器测试的桩 DOM 没有它,而按祖先链走一遍在两边都一样准。
2246
+ */
2247
+ function insidePanelContent(node) {
2248
+ var current = node && node.nodeType === 3 ? node.parentNode : node
2249
+ for (var depth = 0; current && depth < 32; depth += 1) {
2250
+ if (current === body) return true
2251
+ if (current === panel || current === layer || !panel.contains(current)) return false
2252
+ var tag = String(current.tagName || '').toUpperCase()
2253
+ if (tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'BUTTON' || tag === 'IFRAME') return false
2254
+ var cls = typeof current.className === 'string' ? current.className : ''
2255
+ if (cls.indexOf('dsh-sel-quotes') >= 0) return false
2256
+ current = current.parentNode
2257
+ }
2258
+ return false
2259
+ }
2260
+
2261
+ /**
2262
+ * 「小窗第 N 轮回答 / 提问」。
2263
+ *
2264
+ * 小窗里的引用**不带上下文**(那几轮对话本来就会随历史进模型),所以出处全靠标签说清:
2265
+ * 只写"小窗回答"太笼统 —— 第 1 轮和第 4 轮的回答是两回事。
2266
+ */
2267
+ function turnLabelOf(turn) {
2268
+ if (!turn) return '小窗内容'
2269
+ var index = state.turns.indexOf(turn)
2270
+ var round = 0
2271
+ for (var i = 0; i <= index && i < state.turns.length; i += 1) {
2272
+ if (state.turns[i].role === 'user') round += 1
2273
+ }
2274
+ var role = turn.role === 'user' ? '提问' : '回答'
2275
+ return round > 0 ? '小窗第 ' + round + ' 轮' + role : '小窗' + role
2276
+ }
2277
+
2278
+ /** 小窗正文里这段选区的出处:回答(第 N 轮)/ 某一节 / 顶部选中文字条。 */
2279
+ function panelSourceLabel(node) {
2280
+ var current = node && node.nodeType === 3 ? node.parentNode : node
2281
+ for (var depth = 0; current && depth < 32; depth += 1) {
2282
+ if (current.__turn) return turnLabelOf(current.__turn)
2283
+ // 两节用**它们自己的标题**(标题是「翻译」/「解读」/「注释」,随选中内容变):
2284
+ // 写死"小窗解读"会在注释节里指错地方
2285
+ if (current === translationSection.root) return '小窗「' + translationSection.title.textContent + '」节'
2286
+ if (current === detailSection.root) return '小窗「' + detailSection.title.textContent + '」节'
2287
+ var cls = typeof current.className === 'string' ? current.className : ''
2288
+ if (cls.indexOf('dsh-sel-quote') === 0) return '小窗顶部的选中文字'
2289
+ if (current === body || current === panel || !current.parentNode) break
2290
+ current = current.parentNode
2291
+ }
2292
+ return '小窗内容'
2293
+ }
2294
+
2295
+ /**
2296
+ * 小窗开着时的选区检查:这时候浮标是「❝ 引用」而不是「✦ 解读」。
2297
+ *
2298
+ * 两种来源都认:
2299
+ * ① 小窗正文里的选区(回答 / 两节 / 顶部选中文字条)→ 引用的就是小窗自己的内容;
2300
+ * ② 主界面上的选区 → 引用主界面选中的文字。
2301
+ * 帧内(侧边栏网页)那条不在这里 —— 它由桥的 message 送过来,见 offBridgeMessage。
2302
+ */
2303
+ function checkQuoteSelection() {
2304
+ hideButton() // 小窗开着时不提供「解读」:要解读直接在输入框里问
2305
+ var selection = window.getSelection()
2306
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
2307
+ // 帧内那条选区还活着(本文档塌陷只是点浮标造成的)→ 留着浮标
2308
+ if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
2309
+ state.quoteSelection = null
2310
+ hideQuoteButton()
2311
+ return
2312
+ }
2313
+ var range
2314
+ try {
2315
+ range = selection.getRangeAt(0)
2316
+ } catch (error) {
2317
+ return
2318
+ }
2319
+ var text = String(selection.toString() || '')
2320
+ if (!text.trim() || text.length > MAX_QUOTE_SELECTION) {
2321
+ state.quoteSelection = null
2322
+ hideQuoteButton()
2323
+ return
2324
+ }
2325
+ if (insideEditable(range.startContainer)) {
2326
+ state.quoteSelection = null
2327
+ hideQuoteButton()
2328
+ return
2329
+ }
2330
+ var inPanel = insidePanelContent(range.startContainer) || insidePanelContent(range.endContainer)
2331
+ // 小窗里划词:帧内那条让位(两处同时有选区时以本文档为准)
2332
+ state.bridgeActive = false
2333
+ if (!inPanel && (insideOwnUI(range.startContainer) || insideOwnUI(range.endContainer))) {
2334
+ // 历史列表 / 胶囊 / 工具行的选区:引用没有意义,也不该弹浮标
2335
+ state.quoteSelection = null
2336
+ hideQuoteButton()
2337
+ return
2338
+ }
2339
+ var label = inPanel ? panelSourceLabel(range.startContainer) : '主界面选中'
2340
+ var rects = range.getClientRects()
2341
+ var rect = rects && rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect()
2342
+ if (!rect || (rect.width === 0 && rect.height === 0)) {
2343
+ state.quoteSelection = null
2344
+ hideQuoteButton()
2345
+ return
2346
+ }
2347
+ state.quoteSelection = {
2348
+ text: text.trim(),
2349
+ label: label,
2350
+ source: inPanel ? 'panel' : 'document',
2351
+ rect: rect,
2352
+ // 上下文留到**点引用那一刻**才采(每次 selectionchange 都采太贵):
2353
+ // 这里只记下"在哪一轮上"(小窗)/ 选区本身(主界面,用来采局部窗口)
2354
+ turnIndex: inPanel ? turnIndexOfNode(range.startContainer) : -1,
2355
+ range: inPanel ? null : safeCloneRange(range),
2356
+ }
2357
+ showQuoteButton(rect)
2358
+ }
2359
+
2360
+ /** 克隆选区(桩环境/老浏览器没有 cloneRange 就返回 null,后面走"取不到上下文"那条路)。 */
2361
+ function safeCloneRange(range) {
2362
+ try {
2363
+ return typeof range.cloneRange === 'function' ? range.cloneRange() : null
2364
+ } catch (error) {
2365
+ return null
2366
+ }
2367
+ }
2368
+
1942
2369
  function checkSelection() {
1943
- if (panelOpen) return
2370
+ if (panelOpen) {
2371
+ checkQuoteSelection()
2372
+ return
2373
+ }
2374
+ hideQuoteButton()
2375
+ state.quoteSelection = null
1944
2376
  var selection = window.getSelection()
1945
2377
  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
1946
2378
  // 侧边栏网页里的选区**不在本文档**:点浮标会让本文档的选区塌掉
@@ -1991,17 +2423,8 @@ window.__ModuleLoader__.load({
1991
2423
  function showButton(rect) {
1992
2424
  // 浮标也在同一套配色下:它的底色是"页面背景"(浮层自身透明),按它判深浅
1993
2425
  if (!panelOpen) layer.setAttribute('data-theme', isDarkSurface(layer) ? 'dark' : 'light')
1994
- var wasHidden = button.style.display !== 'inline-flex'
1995
- button.style.display = 'inline-flex'
1996
- var width = button.offsetWidth || 62
1997
- var height = button.offsetHeight || 28
1998
- var left = clamp(rect.right - width, 8, Math.max(8, window.innerWidth - width - 8))
1999
- var top = rect.top - height - 7
2000
- if (top < 8) top = Math.min(rect.bottom + 7, window.innerHeight - height - 8)
2001
- button.style.left = Math.round(left) + 'px'
2002
- button.style.top = Math.round(top) + 'px'
2003
2426
  // 只在"浮现"那一次播动效;已经可见时(划选范围被拖动、键盘调整)只平移,避免一直闪
2004
- if (wasHidden) {
2427
+ if (placeFloat(button, rect)) {
2005
2428
  setButtonPop(false)
2006
2429
  void button.offsetWidth // 强制重排,让动画能重播
2007
2430
  setButtonPop(true)
@@ -2013,6 +2436,328 @@ window.__ModuleLoader__.load({
2013
2436
  setButtonPop(false)
2014
2437
  }
2015
2438
 
2439
+ /**
2440
+ * 浮标定位(「解读」与「引用」两个浮标共用一套规则):
2441
+ * 贴选区右下角;上面放不下(贴到视口顶)就翻到选区下方;左右夹回视口内。
2442
+ * 返回"这次是从隐藏变可见" —— 调用方据此决定要不要播浮现动效。
2443
+ */
2444
+ function placeFloat(node, rect) {
2445
+ var wasHidden = node.style.display !== 'inline-flex'
2446
+ node.style.display = 'inline-flex'
2447
+ var width = node.offsetWidth || 62
2448
+ var height = node.offsetHeight || 28
2449
+ var left = clamp(rect.right - width, 8, Math.max(8, window.innerWidth - width - 8))
2450
+ var top = rect.top - height - 7
2451
+ if (top < 8) top = Math.min(rect.bottom + 7, window.innerHeight - height - 8)
2452
+ node.style.left = Math.round(left) + 'px'
2453
+ node.style.top = Math.round(top) + 'px'
2454
+ return wasHidden
2455
+ }
2456
+
2457
+ function setQuotePop(on) {
2458
+ if (on) quoteButton.setAttribute('data-pop', '1')
2459
+ else quoteButton.removeAttribute('data-pop')
2460
+ }
2461
+
2462
+ function showQuoteButton(rect) {
2463
+ if (placeFloat(quoteButton, rect)) {
2464
+ setQuotePop(false)
2465
+ void quoteButton.offsetWidth
2466
+ setQuotePop(true)
2467
+ }
2468
+ }
2469
+
2470
+ function hideQuoteButton() {
2471
+ quoteButton.style.display = 'none'
2472
+ setQuotePop(false)
2473
+ }
2474
+
2475
+ // ────────────────────── 引用(❝):把别处的文字挂进输入框 ──────────────────────
2476
+ //
2477
+ // 两个入口,同一份数据(state.quotes):
2478
+ // ① 划词浮标「❝ 引用」—— 小窗开着时,小窗正文里的选区 或 主界面上的选区;
2479
+ // ② 助手气泡末尾的「❝ 引用整条」(鼠标移上去才出现)—— 整条回答,网页回答先折成 Markdown。
2480
+ // 引用只属于**下一条**提问:发送时拼进这条消息,发完清空。
2481
+
2482
+ /** 引用卡片的 id 计数(内容可能重复,DOM 与删除都靠 id 认人)。 */
2483
+ var quoteSeq = 0
2484
+
2485
+ /** 卡片/气泡上的来源小字。 */
2486
+ function quoteLabelOf(quote) {
2487
+ return quote && quote.label ? String(quote.label) : '引用'
2488
+ }
2489
+
2490
+ /** 卡片/气泡里的单行摘要(完整文本进 title 与发给模型的那份)。 */
2491
+ function quoteBrief(text, max) {
2492
+ var one = String(text || '').replace(/\s+/g, ' ').trim()
2493
+ return one.length > max ? one.slice(0, max) + '…' : one
2494
+ }
2495
+
2496
+ /**
2497
+ * 加一段引用。加不进去也给一句状态 —— 点了浮标"什么都没发生"是最难查的那种观感。
2498
+ * 同一段划两次不再加(引用区是材料清单,不是记事本);超过 MAX_QUOTES 段也不再加。
2499
+ *
2500
+ * `extra` = { context, session }:引用**当时所在**的上下文(见 quoteContextFor),
2501
+ * session=true 表示"这段文字来自主界面会话",发送前会再问 host 要一份按轮取整的干净上下文。
2502
+ */
2503
+ function addQuote(text, label, extra) {
2504
+ var clean = String(text || '').replace(/\u00a0/g, ' ').trim()
2505
+ if (!clean) return false
2506
+ var truncated = clean.length > MAX_QUOTE_CHARS
2507
+ if (truncated) clean = clean.slice(0, MAX_QUOTE_CHARS) + '…'
2508
+ for (var i = 0; i < state.quotes.length; i += 1) {
2509
+ if (state.quotes[i].text === clean) {
2510
+ setStatus('这段已经在引用里了')
2511
+ return false
2512
+ }
2513
+ }
2514
+ if (state.quotes.length >= MAX_QUOTES) {
2515
+ setStatus('引用最多 ' + MAX_QUOTES + ' 段,先删掉一条再加')
2516
+ return false
2517
+ }
2518
+ quoteSeq += 1
2519
+ var context = extra && typeof extra.context === 'string' ? extra.context.trim() : ''
2520
+ state.quotes.push({
2521
+ id: quoteSeq,
2522
+ label: label || '引用',
2523
+ text: clean,
2524
+ context: context,
2525
+ session: !!(extra && extra.session === true),
2526
+ })
2527
+ renderQuotes()
2528
+ refreshAskState()
2529
+ // 输入框按阶段本来可能是藏着的(首轮还在跑):引用一进来就得看得见,顺手聚焦。
2530
+ // 聚焦走**同一个** focusAskBox(preventScroll):这里是用户主动点引用,
2531
+ // 与"开窗自动聚焦"是两回事,不需要走 askFocusPending 那套"别抢人家打字"的判定。
2532
+ askRow.style.display = ''
2533
+ if (composerVisible()) focusAskBox()
2534
+ setStatus(
2535
+ '已加入引用(' +
2536
+ quoteLabelOf(state.quotes[state.quotes.length - 1]) +
2537
+ ' · ' +
2538
+ clean.length +
2539
+ ' 字' +
2540
+ (truncated ? '(原文更长,已截断)' : '') +
2541
+ (context ? ' · 带上下文 ' + context.length + ' 字' : '') +
2542
+ ')',
2543
+ )
2544
+ return true
2545
+ }
2546
+
2547
+ function removeQuote(id) {
2548
+ var next = []
2549
+ for (var i = 0; i < state.quotes.length; i += 1) {
2550
+ if (state.quotes[i].id !== id) next.push(state.quotes[i])
2551
+ }
2552
+ state.quotes = next
2553
+ renderQuotes()
2554
+ refreshAskState()
2555
+ }
2556
+
2557
+ /** 清空待发送的引用(换了一段选中文字 / 回放了另一条历史 —— 那些引用已经不属于这段对话)。 */
2558
+ function clearQuotes() {
2559
+ if (state.quotes.length === 0) return
2560
+ state.quotes = []
2561
+ renderQuotes()
2562
+ refreshAskState()
2563
+ }
2564
+
2565
+ /** 重画引用区(空的时候整块收起来,不占版面)。 */
2566
+ function renderQuotes() {
2567
+ quotesBox.textContent = ''
2568
+ quotesBox.setAttribute('data-show', state.quotes.length > 0 ? '1' : '0')
2569
+ for (var i = 0; i < state.quotes.length; i += 1) {
2570
+ var quote = state.quotes[i]
2571
+ var chip = el('div', 'dsh-sel-quotechip')
2572
+ chip.setAttribute('data-quote', String(quote.id))
2573
+ chip.appendChild(el('span', 'dsh-sel-quotechip-src', '❝ ' + quoteLabelOf(quote)))
2574
+ var brief = el('span', 'dsh-sel-quotechip-text', quoteBrief(quote.text, 42))
2575
+ brief.title = quote.text
2576
+ chip.appendChild(brief)
2577
+ var remove = el('button', 'dsh-sel-quotechip-x', '✕')
2578
+ remove.type = 'button'
2579
+ remove.title = '移除这条引用'
2580
+ remove.setAttribute('aria-label', '移除这条引用')
2581
+ wireQuoteRemove(remove, quote.id)
2582
+ chip.appendChild(remove)
2583
+ quotesBox.appendChild(chip)
2584
+ }
2585
+ }
2586
+
2587
+ /** 卡片上的 ✕(卡片每次重画,监听跟着节点一起走,不需要单独回收)。 */
2588
+ function wireQuoteRemove(node, id) {
2589
+ listen(node, 'click', function (event) {
2590
+ event.stopPropagation()
2591
+ removeQuote(id)
2592
+ })
2593
+ }
2594
+
2595
+ // ── 引用的上下文:只有"模型看不到的东西"才需要带 ──
2596
+ //
2597
+ // · 小窗自己的正文(回答 / 两节 / 顶部原文条):**不带上下文** —— 小窗这几轮对话
2598
+ // 本来就会随 history 进模型,再贴一遍是重复;出处交给标签说清
2599
+ // (「小窗第 2 轮回答」「小窗「翻译」节」)。
2600
+ // · 主界面(会话):带上"引用所在那一组对话 ± 一组"(host 从会话记录里取干净文本)。
2601
+ // · 侧边栏网页:带上帧内桥采的局部窗口。
2602
+ // 后两条都是模型看不到的材料,所以必须带。
2603
+
2604
+ /** 上下文里有没有引用原文(归一化后包含即可:【】是套在外面的,不影响包含关系)。 */
2605
+ function contextHasQuote(context, text) {
2606
+ var haystack = normalizeSpace(context)
2607
+ var needle = normalizeSpace(text)
2608
+ return needle.length > 0 && haystack.indexOf(needle) >= 0
2609
+ }
2610
+
2611
+ /** 选区落在小窗的哪一轮上(往上找带 __turn 的气泡);不在气泡里返回 -1。 */
2612
+ function turnIndexOfNode(node) {
2613
+ var current = node && node.nodeType === 3 ? node.parentNode : node
2614
+ for (var depth = 0; current && depth < 32; depth += 1) {
2615
+ if (current.__turn) {
2616
+ var index = state.turns.indexOf(current.__turn)
2617
+ if (index >= 0) return index
2618
+ }
2619
+ if (current === body || !panel.contains(current)) break
2620
+ current = current.parentNode
2621
+ }
2622
+ return -1
2623
+ }
2624
+
2625
+ /**
2626
+ * 点「❝ 引用」那一刻才采上下文(划选过程中每次 selectionchange 都采太贵)。
2627
+ * 会话里的引用(主界面)先用**局部窗口**兜底,发送前再问 host 要按轮取整的干净上下文。
2628
+ */
2629
+ function quoteContextFor(selection) {
2630
+ if (!selection) return { context: '', session: false }
2631
+ if (selection.source === 'panel') {
2632
+ // 小窗正文:不带上文,只靠标签说清"出自哪里"(见 panelSourceLabel)
2633
+ return { context: '', session: false }
2634
+ }
2635
+ if (selection.source === 'iframe') {
2636
+ // 侧边栏网页:帧内的桥已经把上下文采好了(±1500 字窗口,选中部分用【】标出)
2637
+ return { context: String(selection.context || ''), session: false }
2638
+ }
2639
+ var context = String(selection.context || '')
2640
+ if (!context && selection.range) {
2641
+ try {
2642
+ context = collectContext(rangeSelection(selection.range)).context || ''
2643
+ } catch (error) {
2644
+ context = ''
2645
+ }
2646
+ }
2647
+ return { context: context, session: true }
2648
+ }
2649
+
2650
+ /**
2651
+ * 会话里的引用:向 host 要"引用所在那一组对话 ± 一组"(干净文本只有 host 那边有:
2652
+ * 工具调用/结果、系统提示、harness 注入在 `transcriptOf` 那套规则里已经滤掉了)。
2653
+ * 拿不到(引用不在会话里 / host 不认 / 超时)就保留客户端自己采的那份,不阻塞发送。
2654
+ */
2655
+ function resolveQuoteContexts(quotes) {
2656
+ var jobs = []
2657
+ for (var i = 0; i < quotes.length; i += 1) {
2658
+ if (quotes[i].session === true) jobs.push(quotes[i])
2659
+ }
2660
+ var sessionId = currentSessionId()
2661
+ if (jobs.length === 0 || !sessionId) return Promise.resolve(quotes)
2662
+ return Promise.all(
2663
+ jobs.map(function (quote) {
2664
+ return fetchQuoteContext(sessionId, quote.text).then(function (context) {
2665
+ if (context) quote.context = context
2666
+ return quote
2667
+ })
2668
+ }),
2669
+ ).then(function () {
2670
+ return quotes
2671
+ })
2672
+ }
2673
+
2674
+ /** 取一次会话版引用上下文(带超时;失败一律返回空串走兜底)。 */
2675
+ function fetchQuoteContext(sessionId, text) {
2676
+ var controller = typeof AbortController === 'function' ? new AbortController() : null
2677
+ var timer = setTimeout(function () {
2678
+ if (controller) {
2679
+ try {
2680
+ controller.abort()
2681
+ } catch (error) {
2682
+ /* noop */
2683
+ }
2684
+ }
2685
+ }, QUOTE_CONTEXT_TIMEOUT)
2686
+ timers.push(timer)
2687
+ return fetch(QUOTE_CONTEXT_API, {
2688
+ method: 'POST',
2689
+ headers: { 'content-type': 'application/json' },
2690
+ body: JSON.stringify({ sessionId: sessionId, text: text }),
2691
+ ...(controller ? { signal: controller.signal } : {}),
2692
+ })
2693
+ .then(function (response) {
2694
+ return response.ok ? response.json() : null
2695
+ })
2696
+ .then(function (data) {
2697
+ clearTimeout(timer)
2698
+ if (data && data.ok === true && data.matched === true && typeof data.context === 'string') return data.context
2699
+ return ''
2700
+ })
2701
+ .catch(function () {
2702
+ clearTimeout(timer)
2703
+ return ''
2704
+ })
2705
+ }
2706
+
2707
+ /**
2708
+ * 引用 + 提问 → **真正发给模型**的那条消息。
2709
+ *
2710
+ * 拼在客户端而不是 host:引用本来就是"用户这条消息的一部分",拼好之后
2711
+ * 追问上下文、历史回放、升格全都天然带着它,host 只负责**给上下文**(那条路由)。
2712
+ * 没有引用时**原样返回提问** —— 老路径一个字都不变。
2713
+ *
2714
+ * 每段引用固定给:来源 + 【引用处上下文】(被引用的部分用【】标出)。
2715
+ * 上下文里没带上原文时(被截断、或标记失败)再补一段【引用原文】——
2716
+ * 绝不让模型去猜"引的是哪句"。
2717
+ */
2718
+ function composeQuestion(quotes, question) {
2719
+ var ask = String(question || '').trim()
2720
+ var list = quotes || []
2721
+ if (list.length === 0) return ask
2722
+ var lines = []
2723
+ for (var i = 0; i < list.length; i += 1) {
2724
+ var quote = list[i]
2725
+ var context = String(quote.context || '').trim()
2726
+ lines.push('【引用 ' + (i + 1) + '】(来自' + quoteLabelOf(quote) + ')')
2727
+ if (context) {
2728
+ lines.push('【引用处上下文】(被引用的部分用【】标出)')
2729
+ lines.push(context)
2730
+ if (!contextHasQuote(context, quote.text)) {
2731
+ lines.push('')
2732
+ lines.push('【引用原文】')
2733
+ lines.push(quote.text)
2734
+ }
2735
+ } else {
2736
+ lines.push(quote.text)
2737
+ }
2738
+ lines.push('')
2739
+ }
2740
+ lines.push('【我的问题】')
2741
+ lines.push(ask || QUOTE_ONLY_QUESTION)
2742
+ return lines.join('\n')
2743
+ }
2744
+
2745
+ /** 某一轮"实际发出去"的文本:用户那轮带引用块,助手轮就是正文。 */
2746
+ function sentTextOf(turn) {
2747
+ if (!turn) return ''
2748
+ if (turn.role === 'user') return turn.sent || turn.text || ''
2749
+ return turn.text || ''
2750
+ }
2751
+
2752
+ /** 导出给 host 的轮次(历史落盘 / 升格):用户那轮把引用块一起带走。 */
2753
+ function exportTurns() {
2754
+ var out = []
2755
+ for (var i = 0; i < state.turns.length; i += 1) {
2756
+ out.push({ role: state.turns[i].role, text: sentTextOf(state.turns[i]) })
2757
+ }
2758
+ return out
2759
+ }
2760
+
2016
2761
  // —— 侧边栏网页(iframe)划词桥的父侧接线 ——
2017
2762
 
2018
2763
  /**
@@ -2231,8 +2976,9 @@ window.__ModuleLoader__.load({
2231
2976
  /**
2232
2977
  * 帧内桥报过来的选区:换算坐标 → 复用同一套浮标与面板。
2233
2978
  *
2234
- * 只认"我们桥过的帧"发来的消息(`frameBySource`);面板开着时按主会话的老规矩
2235
- * 不另开一个(要划新词先按 Esc 收起)。
2979
+ * 只认"我们桥过的帧"发来的消息(`frameBySource`)。
2980
+ * 小窗开着时它走**引用**那条路(划侧边栏网页里的文字,同样能挂进输入框);
2981
+ * 小窗关着时才是「✦ 解读」(要划新词开新窗,先按 Esc 收起当前这扇)。
2236
2982
  */
2237
2983
  var offBridgeMessage = listen(window, 'message', function (event) {
2238
2984
  var data = event && event.data
@@ -2248,6 +2994,7 @@ window.__ModuleLoader__.load({
2248
2994
  state.bridgeActive = false
2249
2995
  }
2250
2996
  hideButton()
2997
+ hideQuoteButton()
2251
2998
  return
2252
2999
  }
2253
3000
  if (data.kind === 'clear') {
@@ -2255,18 +3002,21 @@ window.__ModuleLoader__.load({
2255
3002
  state.selection = null
2256
3003
  state.bridgeActive = false
2257
3004
  hideButton()
3005
+ hideQuoteButton()
2258
3006
  }
2259
3007
  return
2260
3008
  }
2261
3009
  if (data.kind !== 'selection' || !data.sel) return
2262
- if (panelOpen) return
2263
3010
  if (!frame.isConnected) return
2264
3011
  var text = String(data.sel.text || '')
2265
3012
  if (!text.trim() || text.length > MAX_SELECTION) return
2266
3013
  var rect = mapFrameRect(frame, data.sel.rect)
2267
3014
  if (!rect) {
2268
3015
  // 选区滚出帧的可视区:把浮标收掉(帧内再滚回来会重新报)
2269
- if (state.selection && state.selection.frame === frame) hideButton()
3016
+ if (state.selection && state.selection.frame === frame) {
3017
+ hideButton()
3018
+ hideQuoteButton()
3019
+ }
2270
3020
  return
2271
3021
  }
2272
3022
  state.selection = {
@@ -2281,6 +3031,19 @@ window.__ModuleLoader__.load({
2281
3031
  label: labelForFrame(frame, data.sel.label),
2282
3032
  }
2283
3033
  state.bridgeActive = true
3034
+ if (panelOpen) {
3035
+ // 小窗开着:帧内这条选区也只是"一段可以引用的文字"(上下文由帧内的桥给)
3036
+ state.quoteSelection = {
3037
+ text: text.trim(),
3038
+ label: state.selection.label || '侧边栏网页',
3039
+ source: 'iframe',
3040
+ rect: rect,
3041
+ context: String(state.selection.context || ''),
3042
+ turnIndex: -1,
3043
+ }
3044
+ showQuoteButton(rect)
3045
+ return
3046
+ }
2284
3047
  showButton(rect)
2285
3048
  })
2286
3049
 
@@ -2392,14 +3155,45 @@ window.__ModuleLoader__.load({
2392
3155
  }
2393
3156
  }
2394
3157
 
3158
+ /**
3159
+ * 点完「✦ 解读」就把**本文档**那次选区收掉。
3160
+ *
3161
+ * 为什么必须收:小窗一开,浮标就切成「❝ 引用」,而引用浮标是按"有没有活选区"判定的 ——
3162
+ * 留着解读用的那次选区,用户在小窗里敲字、按一下方向键(keyup 会触发一次检查),
3163
+ * 就会在小窗上方又冒出一个「❝ 引用」,指的是他刚才解读的那段文字,莫名其妙。
3164
+ *
3165
+ * 为什么能安全收掉:解读要的东西**在 mouseup 那一刻就都抓好了** ——
3166
+ * `state.selection` 里存的是文字副本 + **克隆的 Range**,上下文也是从克隆 Range 采的
3167
+ * (checkSelection → openForSelection),之后的请求、缓存 key、展开详解、历史回放、
3168
+ * 升格都不再读活选区。唯一的代价是那行字的高亮没了 —— 而小窗顶部本来就写着选中文字。
3169
+ *
3170
+ * 顺序要紧:必须在 openForSelection **采完上下文之后**调用 —— 万一环境没有 cloneRange,
3171
+ * 那边会退回读活选区(老浏览器),先清就采不到了。
3172
+ */
3173
+ function clearDocSelection() {
3174
+ try {
3175
+ var selection = window.getSelection && window.getSelection()
3176
+ if (selection && typeof selection.removeAllRanges === 'function' && selection.rangeCount > 0) {
3177
+ selection.removeAllRanges()
3178
+ }
3179
+ } catch (error) {
3180
+ /* 桩环境/老浏览器没有 removeAllRanges:留着就留着,不影响主流程 */
3181
+ }
3182
+ }
3183
+
2395
3184
  function openForSelection(selection) {
2396
3185
  // 侧边栏网页里的选区:父页面读不到那个文档,上下文只能由帧内桥就地采好带过来
2397
3186
  if (selection.source === 'iframe') {
2398
3187
  openPanelWith(selection.text, selection.context || '', selection.label, selection.rect, selection.keyContext)
3188
+ // 帧内那条高亮清不掉(不透明源,父页面碰不到它的选区)——只能让浮标自己收着;
3189
+ // 好在帧内的桥按指纹去重,同一条选区不会反复上报。
2399
3190
  return
2400
3191
  }
2401
3192
  var contextInfo = collectContext(selection.range ? rangeSelection(selection.range) : window.getSelection())
2402
3193
  openPanelWith(selection.text, contextInfo.context, contextInfo.label, selection.rect, contextInfo.keyContext)
3194
+ clearDocSelection()
3195
+ // 选区连同它的记录一起作废:之后要引用就重新划一段(那会走 checkQuoteSelection)
3196
+ state.selection = null
2403
3197
  }
2404
3198
 
2405
3199
  /**
@@ -2418,6 +3212,8 @@ window.__ModuleLoader__.load({
2418
3212
  var cached = cache.get(cacheKey)
2419
3213
  state.cacheKey = cacheKey
2420
3214
  state.turns = []
3215
+ // 换了一段选中文字 = 换了一次对话:上一段攒的引用不该跟过来
3216
+ clearQuotes()
2421
3217
  state.parts = (cached && cached.parts) || { translation: '', detail: '' }
2422
3218
  state.stage = ''
2423
3219
  state.raw = ''
@@ -2689,9 +3485,17 @@ window.__ModuleLoader__.load({
2689
3485
 
2690
3486
  function showPanel(anchor) {
2691
3487
  panelOpen = true
3488
+ // 键盘用户开窗即可打字:这一轮的输入框一旦可见就聚焦一次(见 focusComposerOnce)
3489
+ askFocusPending = true
3490
+ // 小窗一开,浮标就换成「❝ 引用」那一套(解读浮标收掉,引用浮标等新选区)
3491
+ hideButton()
3492
+ hideQuoteButton()
3493
+ state.quoteSelection = null
2692
3494
  panel.style.display = 'flex'
2693
3495
  panel.style.left = '0px'
2694
3496
  panel.style.top = '0px'
3497
+ // 面板刚显示出来才量得到 scrollHeight:草稿里原来的几行要立刻撑开
3498
+ autoGrowAskBox()
2695
3499
  var width = panel.offsetWidth
2696
3500
  var height = panel.offsetHeight
2697
3501
  var left = anchor ? anchor.left : window.innerWidth / 2 - width / 2
@@ -2710,6 +3514,12 @@ window.__ModuleLoader__.load({
2710
3514
  panelOpen = false
2711
3515
  panel.style.display = 'none'
2712
3516
  hideHistoryList()
3517
+ // 小窗收起来了:引用浮标跟着走(它只在"小窗开着"时有意义)
3518
+ hideQuoteButton()
3519
+ state.quoteSelection = null
3520
+ // 正在录音/识别就到此为止:麦**一定要松开**(不能让标签页一直显示"正在使用麦克风"),
3521
+ // 识别出来的文字也没地方放了 —— 连同提示一起静默收掉。
3522
+ cancelVoice()
2713
3523
  // 收起小窗不该被记成"失败":打个标记,让 fail() 走「已停止」而不是「解读失败」
2714
3524
  if (state.request) state.aborted = true
2715
3525
  if (state.request) state.request.abort()
@@ -2843,7 +3653,7 @@ window.__ModuleLoader__.load({
2843
3653
  // 配色按**面板实际底色**选(不看系统偏好):主题实现方式怎么变都不影响
2844
3654
  layer.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
2845
3655
  panel.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
2846
- askRow.style.display = wide || translationReady ? '' : 'none'
3656
+ askRow.style.display = wide || translationReady || state.quotes.length > 0 ? '' : 'none'
2847
3657
  // 展开 CTA:翻译还在跑(含思考期)时先不出现——那时候该看的是等待特效,
2848
3658
  // 摆在下面只会是个灰着的按钮,反而像"没反应";发过追问之后也收起(见 syncExpandCta)。
2849
3659
  syncExpandCta()
@@ -2891,39 +3701,118 @@ window.__ModuleLoader__.load({
2891
3701
  refreshAskState()
2892
3702
  paintPill()
2893
3703
  keepInsideViewport()
2894
- }
2895
-
2896
- /** 内容变高后把面板拉回视口内。 */
2897
- function keepInsideViewport() {
2898
- if (!panelOpen) return
2899
- var rect = panel.getBoundingClientRect()
2900
- var width = Math.min(rect.width, window.innerWidth - 12)
2901
- var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
2902
- var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
2903
- panel.style.left = Math.round(left) + 'px'
2904
- panel.style.top = Math.round(top) + 'px'
3704
+ focusComposerOnce()
2905
3705
  }
2906
3706
 
2907
3707
  /**
2908
- * 剥掉模型伪造的工具调用残渣。
3708
+ * 开窗后**只聚焦一次**输入框(键盘用户不用先 Tab 两下)。
2909
3709
  *
2910
- * 没有工具可用时,模型会照着自己的习惯把调用"写"成正文(实测见过
2911
- * `<ds_safety_tool_call><tool_name>web_search</tool_name>…` 整段喷出来)。
2912
- * 这里在**渲染时**清理(不改 state.raw,流式期间半截标签也不会丢内容)。
3710
+ * 三个克制的地方:
3711
+ * · 等输入框真的可见才聚焦(首轮翻译还在跑时它是藏着的,聚焦藏着的元素没意义);
3712
+ * · 用户已经在别处打字(主会话输入框、侧边栏…)就**不抢**焦点,并就此作罢;
3713
+ * · preventScroll:别因为聚焦把页面滚一下(划完词页面跳走最难受)。
2913
3714
  */
2914
- /**
2915
- * ── 历史折叠 ──────────────────────────────────────────────────────────
2916
- * 追问时历史是"原文逐字带上",于是整页 HTML(实测 8k 字 ≈ 2,150 tokens)
2917
- * 会在之后**每一轮**重发一次,还占掉 20 条窗口里的位置。
2918
- *
2919
- * 这里把它折叠成**结构化 Markdown**(标题/表格行列/列表/强调/代码都保留,
2920
- * 只丢 <style>、class、内联 style、SVG 路径这些"渲染实现细节"),
2921
- * 实测:8,021 字 → 1,146 字,可见文本覆盖率 100.0%,≈2,150 → ≈860 tokens。
2922
- *
2923
- * 三条边界:
2924
- * ① 只作用于**发历史**这一步 —— 面板里渲染的、代码视图复制的永远是原文;
2925
- * ② 折一次就缓存在那一轮(`turn.foldedCache`),之后每轮直接复用,不重折;
2926
- * ③ **最近一轮保持原样** —— 用户说"把上一页的高亮改成蓝色"时仍能改到版式。
3715
+ function focusComposerOnce() {
3716
+ if (!askFocusPending || !panelOpen) return
3717
+ if (!composerVisible()) return
3718
+ // 小窗里正有一段选中文字(用户正在划词、准备点「❝ 引用」):**这一下聚焦会把选区清掉**。
3719
+ // 让位给用户,并且不再补焦 —— 引用浮标是这个功能的主入口,不能因为抢焦点而让它消失。
3720
+ //(实测:开窗后那一帧恰好落在"首轮刚出完"的自动聚焦上,划好的词会瞬间没了。)
3721
+ if (hasPanelSelection()) {
3722
+ askFocusPending = false
3723
+ return
3724
+ }
3725
+ var active = document.activeElement
3726
+ var inside = active && (active === panel || active === askBox || (panel.contains && panel.contains(active)))
3727
+ // 只有"人家正在别处打字"才值得让路。
3728
+ // 注意别把普通的按钮焦点也算进去:点浮标时焦点正好落在浮标那个 <button> 上
3729
+ //(Chrome 点按钮会给它焦点),照"非 body 就不抢"的老写法会把我们自己挡在门外 ——
3730
+ // 这正是"开窗后没有自动聚焦"的原因。
3731
+ if (!inside && isTypingTarget(active)) {
3732
+ askFocusPending = false // 人家真的在别处打字,这次开窗就不再抢
3733
+ return
3734
+ }
3735
+ askFocusPending = false
3736
+ focusAskBox()
3737
+ }
3738
+
3739
+ /** 小窗正文里这会儿有没有一段活选区(划词中/选好了还没点引用)。 */
3740
+ function hasPanelSelection() {
3741
+ try {
3742
+ var selection = window.getSelection && window.getSelection()
3743
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false
3744
+ var range = selection.getRangeAt(0)
3745
+ return insidePanelContent(range.startContainer) || insidePanelContent(range.endContainer)
3746
+ } catch (error) {
3747
+ return false
3748
+ }
3749
+ }
3750
+
3751
+ /** 正在打字的地方(input / textarea / 可编辑区)—— 只有这种焦点值得让路。 */
3752
+ function isTypingTarget(node) {
3753
+ if (!node || node === askBox) return false
3754
+ var tag = String(node.tagName || '').toLowerCase()
3755
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
3756
+ return node.isContentEditable === true
3757
+ }
3758
+
3759
+ /** 把光标交给输入框(preventScroll:别因为聚焦把页面滚一下)。 */
3760
+ function focusAskBox() {
3761
+ try {
3762
+ askBox.focus({ preventScroll: true })
3763
+ } catch (error) {
3764
+ try {
3765
+ askBox.focus()
3766
+ } catch (ignored) {
3767
+ /* 桩环境 */
3768
+ }
3769
+ }
3770
+ }
3771
+
3772
+ /** 输入框这会儿真的看得见吗(藏着的元素聚焦没有意义)。 */
3773
+ function composerVisible() {
3774
+ if (!askRow || askRow.style.display === 'none') return false
3775
+ if (typeof askBox.getBoundingClientRect !== 'function') return true
3776
+ try {
3777
+ var rect = askBox.getBoundingClientRect()
3778
+ if (rect && rect.width === 0 && rect.height === 0) return false
3779
+ } catch (error) {
3780
+ /* noop */
3781
+ }
3782
+ return true
3783
+ }
3784
+
3785
+ /** 内容变高后把面板拉回视口内。 */
3786
+ function keepInsideViewport() {
3787
+ if (!panelOpen) return
3788
+ var rect = panel.getBoundingClientRect()
3789
+ var width = Math.min(rect.width, window.innerWidth - 12)
3790
+ var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
3791
+ var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
3792
+ panel.style.left = Math.round(left) + 'px'
3793
+ panel.style.top = Math.round(top) + 'px'
3794
+ }
3795
+
3796
+ /**
3797
+ * 剥掉模型伪造的工具调用残渣。
3798
+ *
3799
+ * 没有工具可用时,模型会照着自己的习惯把调用"写"成正文(实测见过
3800
+ * `<ds_safety_tool_call><tool_name>web_search</tool_name>…` 整段喷出来)。
3801
+ * 这里在**渲染时**清理(不改 state.raw,流式期间半截标签也不会丢内容)。
3802
+ */
3803
+ /**
3804
+ * ── 历史折叠 ──────────────────────────────────────────────────────────
3805
+ * 追问时历史是"原文逐字带上",于是整页 HTML(实测 8k 字 ≈ 2,150 tokens)
3806
+ * 会在之后**每一轮**重发一次,还占掉 20 条窗口里的位置。
3807
+ *
3808
+ * 这里把它折叠成**结构化 Markdown**(标题/表格行列/列表/强调/代码都保留,
3809
+ * 只丢 <style>、class、内联 style、SVG 路径这些"渲染实现细节"),
3810
+ * 实测:8,021 字 → 1,146 字,可见文本覆盖率 100.0%,≈2,150 → ≈860 tokens。
3811
+ *
3812
+ * 三条边界:
3813
+ * ① 只作用于**发历史**这一步 —— 面板里渲染的、代码视图复制的永远是原文;
3814
+ * ② 折一次就缓存在那一轮(`turn.foldedCache`),之后每轮直接复用,不重折;
3815
+ * ③ **最近一轮保持原样** —— 用户说"把上一页的高亮改成蓝色"时仍能改到版式。
2927
3816
  */
2928
3817
  var FOLD_HISTORY_KEEP_LATEST = true
2929
3818
  var foldCount = 0
@@ -3249,6 +4138,9 @@ window.__ModuleLoader__.load({
3249
4138
  */
3250
4139
  function historyTextOf(turn, keepRaw) {
3251
4140
  if (!turn) return ''
4141
+ // 用户那轮:发出去的是什么就带回什么(带引用时是拼好的那份,见 composeQuestion)。
4142
+ // 引用块必须进历史 —— 否则下一轮模型只看到"上面那段呢?",而"那段"已经在上下文里消失了。
4143
+ if (turn.role === 'user') return sentTextOf(turn)
3252
4144
  if (keepRaw) return turn.text
3253
4145
  if (typeof turn.foldedCache === 'string') return turn.foldedCache
3254
4146
  if (turn.streaming === true) return turn.text
@@ -3311,10 +4203,12 @@ window.__ModuleLoader__.load({
3311
4203
  var payload = state.payload
3312
4204
  if (!payload || !state.cacheKey) return
3313
4205
  var turns = []
4206
+ var exported = exportTurns()
3314
4207
  for (var i = 0; i < state.turns.length; i += 1) {
3315
4208
  var turn = state.turns[i]
3316
4209
  if (turn.hidden === true) continue
3317
- turns.push({ role: turn.role, text: turn.text || '' })
4210
+ // 落盘的是"实际发出去的那份"(用户那轮带引用块):回放出来时这轮对话是自洽的
4211
+ turns.push(exported[i])
3318
4212
  }
3319
4213
  try {
3320
4214
  fetch(HISTORY, {
@@ -3343,6 +4237,8 @@ window.__ModuleLoader__.load({
3343
4237
  var parts = entry.parts || {}
3344
4238
  state.parts = { translation: parts.translation || '', detail: parts.detail || '' }
3345
4239
  state.turns = Array.isArray(entry.turns) ? entry.turns.slice() : []
4240
+ // 回放的是**另一段**对话:输入框里攒的引用跟着清掉
4241
+ clearQuotes()
3346
4242
  state.toolDigest = typeof entry.toolDigest === 'string' ? entry.toolDigest : ''
3347
4243
  state.cacheKey = entry.key
3348
4244
  state.payload = buildPayload(entry.text, entry.context, entry.label)
@@ -3767,7 +4663,12 @@ window.__ModuleLoader__.load({
3767
4663
  if (chatLog.children.length !== visible.length) {
3768
4664
  clearWaiting(chatLog)
3769
4665
  chatLog.textContent = ''
3770
- for (var j = 0; j < visible.length; j++) chatLog.appendChild(bubbleFor(visible[j]))
4666
+ for (var j = 0; j < visible.length; j++) {
4667
+ var bubble = bubbleFor(visible[j])
4668
+ // 气泡记下自己属于哪一轮:引用时要据此取"这一轮 ± 一轮"的上下文
4669
+ bubble.__turn = visible[j]
4670
+ chatLog.appendChild(bubble)
4671
+ }
3771
4672
  // 刚入列的"待答"气泡:此时才挂进 DOM,可以画等待特效了
3772
4673
  var fresh = visible[visible.length - 1]
3773
4674
  if (fresh && fresh.role === 'assistant' && fresh.asking === true && !fresh.text) {
@@ -3783,7 +4684,14 @@ window.__ModuleLoader__.load({
3783
4684
  var turn = visible[visible.length - 1]
3784
4685
  var painted = false
3785
4686
  node.className = 'dsh-sel-bubble ' + (turn.role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot') + (turn.error ? ' dsh-sel-bubble-err' : '')
3786
- if (turn.role === 'user' || turn.error) {
4687
+ if (turn.role === 'user') {
4688
+ // 用户气泡可能带引用块:签名包含引用,别每次重绘都白重建一遍
4689
+ var usig = (turn.text || '') + '|' + (turn.quotes || []).map(function (item) { return item.text }).join('\u0000')
4690
+ if (node.__sig !== usig) {
4691
+ node.__sig = usig
4692
+ fillUserBubble(node, turn)
4693
+ }
4694
+ } else if (turn.error) {
3787
4695
  node.textContent = turn.text
3788
4696
  } else if (turn.asking === true && !turn.text) {
3789
4697
  // 追问等待期:和首轮同一套等待特效(呼吸点 + 实时秒数 + 💭 思考尾巴)
@@ -3803,9 +4711,11 @@ window.__ModuleLoader__.load({
3803
4711
  renderRich(node, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
3804
4712
  }
3805
4713
  paintNotice(node, turn)
4714
+ paintQuoteAll(node, turn)
3806
4715
  painted = true
3807
4716
  }
3808
4717
  if (!painted && turn.role !== 'user') paintNotice(node, turn)
4718
+ if (!painted && turn.role !== 'user') paintQuoteAll(node, turn)
3809
4719
  }
3810
4720
  // 可见性必须在这里定:追问路径只调 renderTurns(),不会触发 paint(),
3811
4721
  // 之前把它挪进 paint() 导致"发送后气泡画进了 display:none 的容器里,小窗没反应"
@@ -3831,20 +4741,83 @@ window.__ModuleLoader__.load({
3831
4741
  existing.textContent = text
3832
4742
  }
3833
4743
 
4744
+ /**
4745
+ * 用户气泡:有引用时**先画引用块、再画问题**(和发给模型的那份顺序一致)。
4746
+ * 只写 textContent 的话引用就看不见了 —— 模型答的是"上面那段",
4747
+ * 而"上面那段"在气泡里根本不存在,回头看会一头雾水。
4748
+ */
4749
+ function fillUserBubble(node, turn) {
4750
+ node.textContent = ''
4751
+ var quotes = turn.quotes || []
4752
+ if (quotes.length > 0) {
4753
+ var box = el('div', 'dsh-sel-bq')
4754
+ for (var i = 0; i < quotes.length; i += 1) {
4755
+ var item = el('div', 'dsh-sel-bqitem')
4756
+ item.appendChild(el('b', null, '❝ ' + quoteLabelOf(quotes[i])))
4757
+ item.appendChild(document.createTextNode(quoteBrief(quotes[i].text, 60)))
4758
+ item.title = quotes[i].text
4759
+ box.appendChild(item)
4760
+ }
4761
+ node.appendChild(box)
4762
+ }
4763
+ if (turn.text) node.appendChild(el('div', null, turn.text))
4764
+ }
4765
+
3834
4766
  function bubbleFor(turn) {
3835
4767
  var bubble = el('div', 'dsh-sel-bubble ' + (turn.role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot'))
3836
4768
  if (turn.role === 'user') {
3837
- bubble.textContent = turn.text
4769
+ fillUserBubble(bubble, turn)
3838
4770
  } else if (turn.error) {
3839
4771
  bubble.className += ' dsh-sel-bubble-err'
3840
4772
  bubble.textContent = turn.text
3841
4773
  } else {
3842
4774
  renderRich(bubble, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
3843
4775
  }
3844
- if (turn.role !== 'user') paintNotice(bubble, turn)
4776
+ if (turn.role !== 'user') {
4777
+ paintNotice(bubble, turn)
4778
+ paintQuoteAll(bubble, turn)
4779
+ }
3845
4780
  return bubble
3846
4781
  }
3847
4782
 
4783
+ /**
4784
+ * 助手气泡末尾的「❝ 引用整条」(鼠标移到这条消息上才浮出来)。
4785
+ *
4786
+ * 每次重画都要补一遍:renderRich() 会先清空气泡,按钮跟着一起没了。
4787
+ * 点的时候现读 `source.text`(一轮还在流式时文本一直在长),并折掉网页回答里的 HTML
4788
+ * —— 引用一整页 HTML 源码既长又没有提问价值,历史里本来也是折成 Markdown 的。
4789
+ */
4790
+ function paintQuoteAll(node, turn) {
4791
+ var existing = null
4792
+ for (var i = 0; i < node.children.length; i += 1) {
4793
+ if (String(node.children[i].className).indexOf('dsh-sel-bubquote') >= 0) existing = node.children[i]
4794
+ }
4795
+ var ready = turn && turn.error !== true && turn.streaming !== true && String(turn.text || '').trim()
4796
+ if (!ready) {
4797
+ if (existing) node.removeChild(existing)
4798
+ return
4799
+ }
4800
+ if (!existing) {
4801
+ existing = el('button', 'dsh-sel-bubquote')
4802
+ existing.type = 'button'
4803
+ existing.appendChild(el('span', null, '❝ 引用整条'))
4804
+ node.appendChild(existing)
4805
+ }
4806
+ existing.title = '把这一整条回答加进追问的引用'
4807
+ existing.__turn = turn
4808
+ if (existing.__wired !== true) {
4809
+ existing.__wired = true
4810
+ listen(existing, 'click', function (event) {
4811
+ event.stopPropagation()
4812
+ var source = existing.__turn
4813
+ if (!source) return
4814
+ var body = foldForHistory(sanitizeToolResidue(source.text))
4815
+ // 整条引用同样不带上下文:出处写进标签(「小窗第 N 轮回答」),对话本身在历史里
4816
+ addQuote(body, turnLabelOf(source), { context: '', session: false })
4817
+ })
4818
+ }
4819
+ }
4820
+
3848
4821
  /** 把首轮解读结果作为追问的第一条助手上下文(截断,避免过长)。 */
3849
4822
  function seedHistoryFromExplanation() {
3850
4823
  var fallback = splitSections(sanitizeToolResidue(state.raw))
@@ -3871,7 +4844,39 @@ window.__ModuleLoader__.load({
3871
4844
  * 发送键 = 「发送」还是「停止」:生成中变成方形停止键(点了打断这次输出),
3872
4845
  * 空闲时是上箭头发送键(空输入才禁用)。
3873
4846
  */
4847
+ /**
4848
+ * 输入框跟着内容长高(到 132px 为止,再高就内部滚动)。
4849
+ *
4850
+ * textarea 的**高度由 rows 决定**,内容换行它不会自己长 —— 不补这一步,第二行起
4851
+ * 下面的字就被裁掉了。语音输入的实时字幕最吃亏:字是边说边自己长出来的,
4852
+ * 用户根本没在敲键盘,也就不会想到去拖那个框。
4853
+ * 超过上限时把最新那几行滚进视野(实时落字时光标一直在末尾)。
4854
+ */
4855
+ function autoGrowAskBox() {
4856
+ if (!askBox || !askBox.style) return
4857
+ var cap = 132
4858
+ // 先松开高度再量,否则量到的是上一次的高度(经典递归式变高)
4859
+ askBox.style.height = 'auto'
4860
+ var content = typeof askBox.scrollHeight === 'number' ? askBox.scrollHeight : 0
4861
+ if (content > 0) {
4862
+ askBox.style.height = Math.min(content, cap) + 'px'
4863
+ askBox.style.overflowY = content > cap ? 'auto' : 'hidden'
4864
+ } else {
4865
+ // 量不到内容(面板收着 / DOM 桩):交回 CSS 的 min-height
4866
+ askBox.style.height = ''
4867
+ askBox.style.overflowY = ''
4868
+ }
4869
+ try {
4870
+ if (content > cap) askBox.scrollTop = askBox.scrollHeight
4871
+ } catch (error) {
4872
+ /* 桩环境没有 scrollTop 语义 */
4873
+ }
4874
+ // 长高之后面板可能顶出视口:拉回来(面板没开时它自己会早退)
4875
+ keepInsideViewport()
4876
+ }
4877
+
3874
4878
  function refreshAskState() {
4879
+ autoGrowAskBox()
3875
4880
  var generating = isGenerating()
3876
4881
  var mode = generating ? 'stop' : 'send'
3877
4882
  if (askSend.getAttribute('data-mode') !== mode) {
@@ -3881,7 +4886,7 @@ window.__ModuleLoader__.load({
3881
4886
  askSend.setAttribute('aria-label', generating ? '停止' : '发送')
3882
4887
  askSend.title = generating ? '停止生成(点一下打断这次输出)' : '发送(Enter)'
3883
4888
  }
3884
- askSend.disabled = generating ? false : !String(askBox.value || '').trim()
4889
+ askSend.disabled = generating ? false : !String(askBox.value || '').trim() && state.quotes.length === 0
3885
4890
  }
3886
4891
 
3887
4892
  /** 停止当前这一轮:追问走自己的 abort;首轮/详解复用"用户中止"那条路径。 */
@@ -3908,7 +4913,13 @@ window.__ModuleLoader__.load({
3908
4913
  return false
3909
4914
  }
3910
4915
 
3911
- /** 发送一个追问(多轮,带历史;走 host 的 chat 模式)。 */
4916
+ /**
4917
+ * 发送一个追问(多轮,带历史;走 host 的 chat 模式)。
4918
+ *
4919
+ * 两段式:先**取引用的上下文**(会话里那几段要问 host 要,见 resolveQuoteContexts),
4920
+ * 再真正发。取上下文期间用 state.resolvingQuotes 挡住重复发送 ——
4921
+ * 那一刻输入框和引用区都已经清空了,再按一次 Enter 会变成"空发一条"。
4922
+ */
3912
4923
  function ask(question) {
3913
4924
  var generating = state.asking || state.phase === 'loading' || state.phase === 'streaming'
3914
4925
  if (generating) {
@@ -3916,12 +4927,47 @@ window.__ModuleLoader__.load({
3916
4927
  setStatus('还在生成,请稍候…(生成完就能追问)')
3917
4928
  return
3918
4929
  }
3919
- if (!question.trim() || !state.payload) return
4930
+ // 这条消息发出去了,正在录的那一段就不再是"下一条提问"了:
4931
+ // 收掉它可以避免"发送后麦克风还红着"(用户会以为还在录)。
4932
+ // 已经识别出来的文字在输入框里,不受影响。
4933
+ if (voice.phase === 'recording' || voice.phase === 'requesting' || voice.phase === 'transcribing') {
4934
+ cancelVoice({ note: '已停止录音(这条先发出去)' })
4935
+ }
4936
+ var asked = String(question || '').trim()
4937
+ // 引用先取出来:只挂引用、没写问题也允许发(这时用一句兜底提问)
4938
+ var quotes = state.quotes.slice()
4939
+ if ((!asked && quotes.length === 0) || !state.payload) return
4940
+ if (state.resolvingQuotes) {
4941
+ setStatus('正在取引用的上下文…')
4942
+ return
4943
+ }
4944
+ // 引用是"下一条提问"的:立刻清空(提问已经在手上,别让下一轮又带上)
4945
+ if (quotes.length > 0) {
4946
+ state.quotes = []
4947
+ renderQuotes()
4948
+ refreshAskState()
4949
+ }
4950
+ if (quotes.length === 0) {
4951
+ sendAsk(asked, quotes)
4952
+ return
4953
+ }
4954
+ state.resolvingQuotes = true
4955
+ resolveQuoteContexts(quotes).then(function (resolved) {
4956
+ state.resolvingQuotes = false
4957
+ sendAsk(asked, resolved)
4958
+ })
4959
+ }
4960
+
4961
+ /** 真正把这一轮发出去(引用已经带上上下文了)。 */
4962
+ function sendAsk(asked, quotes) {
3920
4963
  var payload = state.payload
4964
+ if (!payload) return
4965
+ // 引用拼进**这一条**消息:发给模型、进历史、升格用的都是拼好的那份
4966
+ var sent = composeQuestion(quotes, asked)
3921
4967
  if (state.turns.length === 0 && (state.raw.trim() || state.parts.translation || state.parts.detail)) {
3922
4968
  state.turns.push({ role: 'assistant', text: seedHistoryFromExplanation(), seed: true, hidden: true })
3923
4969
  }
3924
- state.turns.push({ role: 'user', text: question })
4970
+ state.turns.push({ role: 'user', text: asked || QUOTE_ONLY_QUESTION, quotes: quotes, sent: sent })
3925
4971
  // asking 只管"等待特效要不要显示"(首字一到就置 false),
3926
4972
  // streaming 才是"这一轮还没写完"。两者必须分开:以前拿 asking 当定稿判据,
3927
4973
  // 结果是首字一到就认定"定稿了"→ 每个 delta 都重建一个 iframe(实测一条回答建了 24 个),
@@ -4018,7 +5064,7 @@ window.__ModuleLoader__.load({
4018
5064
  context: payload.context,
4019
5065
  label: payload.label,
4020
5066
  sessionId: currentSessionId(),
4021
- question: question,
5067
+ question: sent,
4022
5068
  history: history,
4023
5069
  ...(state.webAnswer ? { webAnswer: true } : {}),
4024
5070
  ...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
@@ -4510,7 +5556,8 @@ window.__ModuleLoader__.load({
4510
5556
  label: payload.label,
4511
5557
  translation: (sections.translation || '').trim(),
4512
5558
  detail: (sections.detail || '').trim(),
4513
- turns: state.turns,
5559
+ // 带引用块的轮次:升格出来的会话里,用户当时发的就是拼好的那份
5560
+ turns: exportTurns(),
4514
5561
  }),
4515
5562
  })
4516
5563
  .then(function (response) {
@@ -4587,8 +5634,15 @@ window.__ModuleLoader__.load({
4587
5634
  setButtonPop(false)
4588
5635
  })
4589
5636
 
5637
+ var offQuotePopEnd = listen(quoteButton, 'animationend', function () {
5638
+ setQuotePop(false)
5639
+ })
5640
+
4590
5641
  var offMouseUp = listen(document, 'mouseup', function (event) {
4591
- if (panel.contains(event.target) || button.contains(event.target)) return
5642
+ // 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
5643
+ // (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
5644
+ // 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
5645
+ if (button.contains(event.target) || quoteButton.contains(event.target)) return
4592
5646
  scheduleCheck()
4593
5647
  }, true)
4594
5648
 
@@ -4609,6 +5663,7 @@ window.__ModuleLoader__.load({
4609
5663
  if (historyList.contains(target)) return // 列表内部:行自己处理
4610
5664
  if (historyButton.contains(target)) return // 「最近」开关自己 toggle
4611
5665
  if (button.contains(target)) return // 浮标
5666
+ if (quoteButton.contains(target)) return // 引用浮标:点它不能先把自己收掉
4612
5667
  // 状态胶囊:它自己就是开关,点它的 mousedown 不能被当成"点了外面"(否则先关后开,看着像没反应)
4613
5668
  if (pill.contains(target)) return
4614
5669
  // 到这里说明点的不是侧边栏本身:**含面板正文**(消息区/输入框/选中文字)在内,
@@ -4616,6 +5671,7 @@ window.__ModuleLoader__.load({
4616
5671
  hideHistoryList()
4617
5672
  if (panel.contains(target)) return // 面板内的点击只收侧边栏,不关面板
4618
5673
  hideButton()
5674
+ hideQuoteButton()
4619
5675
  // 点面板外**不关小窗**(无论有没有追问过):答案留在原地,只有 ✕ / Esc / 胶囊能收。
4620
5676
  // 曾经的规则是"没追问过才关",但那会让用户在读第一屏翻译时被误关(点一下别处就没了)。
4621
5677
  }, true)
@@ -4633,8 +5689,26 @@ window.__ModuleLoader__.load({
4633
5689
  if (selection) openForSelection(selection)
4634
5690
  })
4635
5691
 
5692
+ // 引用浮标:吞掉 mousedown(点了不能把选区打散、也不能让面板失焦),
5693
+ // 点一下 = 把这段文字挂进输入框的引用区,然后自己收起来("已经进去了"的信号)
5694
+ var offQuoteDown = listen(quoteButton, 'mousedown', function (event) {
5695
+ event.preventDefault()
5696
+ event.stopPropagation()
5697
+ })
5698
+
5699
+ var offQuoteClick = listen(quoteButton, 'click', function (event) {
5700
+ event.preventDefault()
5701
+ event.stopPropagation()
5702
+ var selection = state.quoteSelection
5703
+ if (!selection) return
5704
+ // 上下文在这里才采:引用**当时**在哪一段对话里,是这条引用最有用的信息
5705
+ addQuote(selection.text, selection.label, quoteContextFor(selection))
5706
+ hideQuoteButton()
5707
+ })
5708
+
4636
5709
  var offScroll = listen(window, 'scroll', function (event) {
4637
5710
  hideButton()
5711
+ hideQuoteButton()
4638
5712
  // 页面/面板滚动也收回侧边栏;滚动列表自身不算(它有自己的滚动条)。
4639
5713
  // event.target 不一定是 Node(合成事件可能是 window),contains 会抛,得自己挡一道
4640
5714
  var target = event && event.target
@@ -5044,6 +6118,9 @@ window.__ModuleLoader__.load({
5044
6118
 
5045
6119
  var offResize = listen(window, 'resize', function () {
5046
6120
  hideButton()
6121
+ hideQuoteButton()
6122
+ // 宽度变了 → 换行位置变了 → 输入框高度要重量(否则会是按旧宽度算出来的高度)
6123
+ autoGrowAskBox()
5047
6124
  // 拖窗口变小后,面板本身也要拉回视口内(否则历史列表会跟着算到屏幕外)
5048
6125
  keepInsideViewport()
5049
6126
  if (historyList.style.display === 'flex') placeHistoryList()
@@ -5053,13 +6130,20 @@ window.__ModuleLoader__.load({
5053
6130
  })
5054
6131
 
5055
6132
  var offKeyDown = listen(document, 'keydown', function (event) {
5056
- // Esc:**任何时候、一次到位**关掉小窗(不受"有没有追问过"限制)。
5057
- // closePanel() 内部会顺带收起模型菜单与「最近」侧边栏,所以不再需要"第一下只收侧边栏"那一级。
5058
- if (event.key === 'Escape' && panelOpen) {
6133
+ if (event.key !== 'Escape' || !panelOpen) return
6134
+ // Esc 分两级,和主会话一致:**先退出正在进行的事,再关窗**。
6135
+ // ① 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着),面板留着;
6136
+ // ② 其余情况 → 关掉小窗(一次到位,不受"有没有追问过"限制)。
6137
+ // 以前是一律关窗:正在说一句话时按 Esc 想放弃这句话,结果整个小窗没了。
6138
+ if (captureRow.getAttribute('data-show') === '1') {
5059
6139
  event.preventDefault()
5060
6140
  event.stopPropagation()
5061
- closePanel()
6141
+ cancelVoice()
6142
+ return
5062
6143
  }
6144
+ event.preventDefault()
6145
+ event.stopPropagation()
6146
+ closePanel()
5063
6147
  }, true)
5064
6148
 
5065
6149
  var offHistory = listen(historyButton, 'click', function (event) {
@@ -5090,7 +6174,8 @@ window.__ModuleLoader__.load({
5090
6174
  event.preventDefault()
5091
6175
  event.stopPropagation()
5092
6176
  var question = askBox.value
5093
- if (!question.trim()) return
6177
+ // 空输入框照发**只在挂了引用时**成立(引用本身就是用户要送出去的内容)
6178
+ if (!question.trim() && state.quotes.length === 0) return
5094
6179
  askBox.value = ''
5095
6180
  refreshAskState()
5096
6181
  ask(question)
@@ -5107,12 +6192,1253 @@ window.__ModuleLoader__.load({
5107
6192
  return
5108
6193
  }
5109
6194
  var question = askBox.value
5110
- if (!question.trim()) return
6195
+ if (!question.trim() && state.quotes.length === 0) return
5111
6196
  askBox.value = ''
5112
6197
  refreshAskState()
5113
6198
  ask(question)
5114
6199
  })
5115
6200
 
6201
+ // ══════════════════════════ 语音输入(麦克风) ══════════════════════════
6202
+ //
6203
+ // 要解决的问题:追问往往只有一两句话("这词在这里是不是贬义?"),
6204
+ // 但**打字**这件事本身就要把手从鼠标挪到键盘——和"划词"这个动作是矛盾的。
6205
+ //
6206
+ // **UI 与主会话同一套**(官方 @deepseek-ai/dsh-experimental-client-ui-voice-input):
6207
+ // 平时是输入框右边的 🎤;开始录之后工具行换成录音行 ——`✕` | 实时波形 | `■`,
6208
+ // 请求权限/识别中是「呼吸点 + 请允许使用麦克风…/识别中…」,出错是「原因 + 行内动作」。
6209
+ // 波形算法(80 根线 / 50ms 采样 / 1+min(1,amp*5)*17 / 右侧最新)逐行照抄,
6210
+ // 只有尺寸按小窗的 composer 收成 26px(主会话 32px)——两处看起来才是同一个东西。
6211
+ //
6212
+ // 识别出来的文字**插进输入框**(不是直接发送:识别有错字,插进去还能改,
6213
+ // 也能在同一条里接着说第二段)。录音中切走/收起小窗/这条消息发出去/停用插件
6214
+ // 都会立刻松开麦克风(不留下一直亮着的录音标识)。
6215
+
6216
+ /** 状态机:idle → requesting → recording → transcribing →(feedback | idle)→ idle。 */
6217
+ var voice = {
6218
+ phase: 'idle',
6219
+ /**
6220
+ * 第几轮。停/取消/关面板都会 +1 —— 晚到的回调据此丢弃自己。
6221
+ * 没有它就会出现"明明取消了,过两秒却冒出一段文字"(异步的权限、识别都可能晚到)。
6222
+ */
6223
+ generation: 0,
6224
+ capture: null,
6225
+ abort: null,
6226
+ ticker: 0,
6227
+ waveFrame: 0,
6228
+ waveAt: 0,
6229
+ startedAt: 0,
6230
+ maxSeconds: VOICE_FALLBACK_SECONDS,
6231
+ catalog: null,
6232
+ catalogAt: 0,
6233
+ idleTimer: 0,
6234
+ pollTimer: 0,
6235
+ lastClock: -1,
6236
+ level: 0,
6237
+ lastText: '',
6238
+ /** feedback 阶段的行内动作(null = 只显示原因,不给按钮)。 */
6239
+ action: null,
6240
+ /**
6241
+ * 实时字幕(半句预览 + 停顿定稿)的运行时状态。
6242
+ * text/committed/preview 都是输入框里那一段的一部分,start 是它在 value 里的起点。
6243
+ */
6244
+ live: {
6245
+ active: false,
6246
+ disabled: false,
6247
+ reason: '',
6248
+ separator: '',
6249
+ start: -1,
6250
+ text: '',
6251
+ committed: '',
6252
+ preview: '',
6253
+ boundary: 0,
6254
+ phraseStart: 0,
6255
+ inflight: false,
6256
+ controller: null,
6257
+ timer: 0,
6258
+ tickAt: 0,
6259
+ startedAt: 0,
6260
+ previewFails: 0,
6261
+ passes: { preview: 0, commit: 0 },
6262
+ rewritten: false,
6263
+ },
6264
+ }
6265
+
6266
+ /** 录音失败的统一话术(浏览器抛的是 DOMException,用户看不懂)。 */
6267
+ var VOICE_ERRORS = {
6268
+ unavailable: '当前浏览器不支持录音,请使用支持麦克风的浏览器。',
6269
+ permission: '麦克风权限未开启,请在浏览器和系统设置中允许访问。',
6270
+ missing: '没有找到麦克风设备。',
6271
+ interrupted: '录音中断,请重试。',
6272
+ empty: '未识别到语音',
6273
+ cancelled: '已取消语音输入。',
6274
+ }
6275
+
6276
+ function voiceError(kind, extra) {
6277
+ var error = new Error(VOICE_ERRORS[kind] || '录音失败')
6278
+ error.voiceKind = kind
6279
+ if (extra) error.cause = extra
6280
+ return error
6281
+ }
6282
+
6283
+ /** 出错信息 → 一句人话。 */
6284
+ function voiceMessageOf(error) {
6285
+ if (!error) return '未知错误'
6286
+ if (error.voiceKind) return VOICE_ERRORS[error.voiceKind] || '录音失败'
6287
+ return String(error.message || error)
6288
+ }
6289
+
6290
+ /**
6291
+ * 状态行文案:
6292
+ * @param text 文案
6293
+ * @param options { dot: 显示呼吸点, tone: '' | 'warn' | 'error' }
6294
+ */
6295
+ function setVoiceActivity(text, options) {
6296
+ var opts = options || {}
6297
+ voiceActivityText.textContent = String(text || '')
6298
+ voiceActivity.setAttribute('data-tone', opts.tone || '')
6299
+ voiceDot.style.display = opts.dot ? '' : 'none'
6300
+ }
6301
+
6302
+ /** 行内动作位:'prepare'(准备模型文字键)/ null(用 🎤 重录)/ 'none'(什么都不要)。 */
6303
+ function setVoiceAction(kind) {
6304
+ while (voiceActionSlot.firstChild) voiceActionSlot.removeChild(voiceActionSlot.firstChild)
6305
+ voice.action = kind || null
6306
+ if (kind === 'prepare') {
6307
+ var prepareBtn = el('button', 'dsh-sel-vact', '准备模型')
6308
+ prepareBtn.type = 'button'
6309
+ prepareBtn.title = '在本机下载并准备识别模型(首次要下载,之后一直可用)'
6310
+ listen(prepareBtn, 'click', function (event) {
6311
+ event.stopPropagation()
6312
+ prepareVoice()
6313
+ })
6314
+ voiceActionSlot.appendChild(prepareBtn)
6315
+ return prepareBtn
6316
+ }
6317
+ if (kind === 'retry') {
6318
+ var retryMic = el('button', 'dsh-sel-round')
6319
+ retryMic.type = 'button'
6320
+ retryMic.setAttribute('aria-label', '重新录音')
6321
+ retryMic.title = '重新录音'
6322
+ retryMic.appendChild(micIcon())
6323
+ listen(retryMic, 'click', function (event) {
6324
+ event.stopPropagation()
6325
+ startVoice()
6326
+ })
6327
+ voiceActionSlot.appendChild(retryMic)
6328
+ return retryMic
6329
+ }
6330
+ return null
6331
+ }
6332
+
6333
+ /**
6334
+ * 出错/提示:录音行里给一句原因 +(有救的)一个动作。`retryable` 为真时给 🎤 重录键。
6335
+ * 这是主会话的 feedback 阶段:它就一直停在那儿,直到用户点 ✕(或行内动作)。
6336
+ */
6337
+ function voiceFeedback(text, tone, action) {
6338
+ voice.phase = 'feedback'
6339
+ setVoiceActivity(text, { tone: tone || '' })
6340
+ setVoiceAction(action)
6341
+ paintVoice()
6342
+ }
6343
+
6344
+ /** 按状态摆好录音行:idle 显 🎤,其余显录音行(✕ / 波形或文案 / ■ 或行内动作)。 */
6345
+ function paintVoice() {
6346
+ var phase = voice.phase
6347
+ var capturing = phase !== 'idle'
6348
+ captureRow.setAttribute('data-show', capturing ? '1' : '0')
6349
+ captureRow.style.display = capturing ? 'flex' : 'none'
6350
+ // 录音行出现时,工具行左边那三样(输出偏好 / 模型 / 🎤)让位——和主会话一样,
6351
+ // 录音时那行只讲一件事:怎么结束、结束之后会得到什么。
6352
+ webMode.style.display = capturing ? 'none' : ''
6353
+ askSpacer.style.display = capturing ? 'none' : ''
6354
+ modelPill.style.display = capturing ? 'none' : ''
6355
+ micButton.style.display = capturing ? 'none' : ''
6356
+ // 中间那格:录音时是波形,其余是文案;■ 只在录音时出现
6357
+ var recording = phase === 'recording'
6358
+ waveSvg.style.display = recording ? '' : 'none'
6359
+ voiceActivity.style.display = recording ? 'none' : 'flex'
6360
+ voiceStop.style.display = recording ? 'inline-grid' : 'none'
6361
+ voiceCancel.title = phase === 'feedback' ? '关闭(Esc)' : '取消这次语音(Esc,已经说出来的字留着)'
6362
+ voiceCancel.setAttribute('aria-label', voiceCancel.title)
6363
+ // 🎤 触发键的状态:只在"请求权限"时转圈(其余都是普通图标 —— 和主会话一致)
6364
+ micButton.setAttribute('data-state', phase === 'requesting' ? 'requesting' : 'idle')
6365
+ micButton.setAttribute('aria-pressed', capturing ? 'true' : 'false')
6366
+ micButton.title = '语音输入(点一下开始说,再点一下结束并转成文字)'
6367
+ }
6368
+
6369
+ /** 识别器:目录里默认那个(没有就第一个)。 */
6370
+ function voiceProvider(catalog) {
6371
+ if (!catalog || !catalog.providers || !catalog.providers.length) return null
6372
+ var id = catalog.selection && catalog.selection.providerId
6373
+ for (var i = 0; i < catalog.providers.length; i += 1) {
6374
+ if (catalog.providers[i].id === id) return catalog.providers[i]
6375
+ }
6376
+ return catalog.providers[0]
6377
+ }
6378
+
6379
+ /** 目录(有哪些识别器、能不能录、能录多久):短暂缓存,别每点一次都问一遍 host。 */
6380
+ function fetchSpeechCatalog(force) {
6381
+ var now = Date.now()
6382
+ if (!force && voice.catalog && now - voice.catalogAt < VOICE_CATALOG_TTL) {
6383
+ return Promise.resolve(voice.catalog)
6384
+ }
6385
+ return fetch(SPEECH_API)
6386
+ .then(function (response) {
6387
+ return response.json()
6388
+ })
6389
+ .then(function (data) {
6390
+ voice.catalog = data
6391
+ voice.catalogAt = Date.now()
6392
+ return data
6393
+ })
6394
+ }
6395
+
6396
+ /**
6397
+ * 波形推进一帧(算法照抄主会话的 Waveform):
6398
+ * 新的电平从右边挤进来,整排往左推;静音时每条线只有 2px 高 —— 就是那条虚线。
6399
+ * @param level 本帧电平(0 = 不采样,只把尺寸摆正)
6400
+ */
6401
+ function repaintWave(level) {
6402
+ var next = level
6403
+ for (var i = 0; i < waveBars.length; i += 1) {
6404
+ var bar = waveBars[i]
6405
+ var previous = bar.level
6406
+ bar.level = next
6407
+ next = previous
6408
+ var height = 1 + Math.min(1, bar.level * 5) * 17
6409
+ bar.node.setAttribute('y1', String(20 - height))
6410
+ bar.node.setAttribute('y2', String(20 + height))
6411
+ }
6412
+ }
6413
+
6414
+ /** 把波形清回静音基线(新一次录音从头开始画;主会话是重建 SVG,效果一样)。 */
6415
+ function resetWave() {
6416
+ for (var i = 0; i < waveBars.length; i += 1) {
6417
+ waveBars[i].level = 0
6418
+ waveBars[i].node.setAttribute('y1', '19')
6419
+ waveBars[i].node.setAttribute('y2', '21')
6420
+ }
6421
+ }
6422
+
6423
+ function stopWaveLoop() {
6424
+ if (voice.waveFrame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(voice.waveFrame)
6425
+ voice.waveFrame = 0
6426
+ voice.waveAt = 0
6427
+ }
6428
+
6429
+ /** 录音时的波形循环:50ms 采一次音量(和主会话同一个节拍)。 */
6430
+ function startWaveLoop() {
6431
+ stopWaveLoop()
6432
+ if (typeof requestAnimationFrame !== 'function') return
6433
+ var draw = function (now) {
6434
+ if (voice.phase !== 'recording') return
6435
+ // rAF 会给时间戳,但不是所有环境都给(桩、老实现)——缺了就用当前时间,别让波形卡住
6436
+ var at = typeof now === 'number' ? now : Date.now()
6437
+ if (at - voice.waveAt >= 50) {
6438
+ voice.waveAt = at
6439
+ var level = 0
6440
+ try {
6441
+ level = voice.capture ? voice.capture.level() : 0
6442
+ } catch (error) {
6443
+ level = 0
6444
+ }
6445
+ voice.level = level
6446
+ repaintWave(level)
6447
+ }
6448
+ voice.waveFrame = requestAnimationFrame(draw)
6449
+ }
6450
+ voice.waveFrame = requestAnimationFrame(draw)
6451
+ }
6452
+
6453
+ function stopVoiceTicker() {
6454
+ if (voice.ticker) clearInterval(voice.ticker)
6455
+ voice.ticker = 0
6456
+ voice.lastClock = -1
6457
+ stopWaveLoop()
6458
+ }
6459
+
6460
+ /**
6461
+ * 一次录音的全部资源:麦克风流、MediaRecorder、AudioContext、分析器。
6462
+ *
6463
+ * 这几个东西**必须一起释放**:只停 MediaRecorder 的话,标签页上会一直挂着
6464
+ * "正在使用麦克风"的标识(用户会以为在偷听),AudioContext 也会一直占着音频线程。
6465
+ */
6466
+ function createCapture(onError) {
6467
+ var stream = null
6468
+ var recorder = null
6469
+ var context = null
6470
+ var analyser = null
6471
+ var samples = new Float32Array(256)
6472
+ var chunks = []
6473
+ var lifetime = new AbortController()
6474
+ var disposal = null
6475
+ // ── 采样水龙头(实时字幕用)──
6476
+ var tap = null
6477
+ var tapGain = null
6478
+ /** 16k 采样的分片表(按时序),tapLength 是总采样数。 */
6479
+ var tapChunks = []
6480
+ var tapLength = 0
6481
+ /** 线性重采样的分数位置:跨回调连续,接缝处才不会每 93ms 丢一个点。 */
6482
+ var tapPos = 0
6483
+ /** 最近一次"有声"的采样位置与墙钟时间(停顿定稿靠它切边界)。 */
6484
+ var tapLoudIndex = -1
6485
+ var tapLoudAt = 0
6486
+
6487
+ /** 把一块原始采样(context 采样率)重采样到 16k 攒起来,同时判"有声/静音"。 */
6488
+ function acceptChunk(input) {
6489
+ var rate = context && context.sampleRate ? context.sampleRate : 48000
6490
+ var step = rate / 16000
6491
+ var out = []
6492
+ var pos = tapPos
6493
+ while (pos + 1 < input.length) {
6494
+ var index = Math.floor(pos)
6495
+ var frac = pos - index
6496
+ out.push(input[index] * (1 - frac) + input[index + 1] * frac)
6497
+ pos += step
6498
+ }
6499
+ tapPos = pos - input.length
6500
+ if (out.length) {
6501
+ var piece = Float32Array.from(out)
6502
+ tapChunks.push(piece)
6503
+ tapLength += piece.length
6504
+ }
6505
+ var sum = 0
6506
+ for (var i = 0; i < input.length; i += 1) sum += input[i] * input[i]
6507
+ if (Math.sqrt(sum / input.length) >= VOICE_SILENCE_RMS) {
6508
+ tapLoudIndex = tapLength
6509
+ tapLoudAt = Date.now()
6510
+ }
6511
+ }
6512
+
6513
+ /** 取 [from, to) 的 16k 采样(越界自动夹住)。 */
6514
+ function readSamples(from, to) {
6515
+ var a = Math.max(0, Math.floor(from))
6516
+ var b = Math.min(tapLength, Math.ceil(to))
6517
+ if (b <= a) return new Float32Array(0)
6518
+ var out = new Float32Array(b - a)
6519
+ var cursor = 0
6520
+ for (var i = 0; i < tapChunks.length; i += 1) {
6521
+ var piece = tapChunks[i]
6522
+ var end = cursor + piece.length
6523
+ if (end > a && cursor < b) {
6524
+ var s = Math.max(0, a - cursor)
6525
+ var e = Math.min(piece.length, b - cursor)
6526
+ out.set(piece.subarray(s, e), cursor + s - a)
6527
+ }
6528
+ cursor = end
6529
+ if (cursor >= b) break
6530
+ }
6531
+ return out
6532
+ }
6533
+
6534
+ function release() {
6535
+ try {
6536
+ lifetime.abort()
6537
+ } catch (error) {
6538
+ /* noop */
6539
+ }
6540
+ if (tap) {
6541
+ try {
6542
+ tap.onaudioprocess = null
6543
+ tap.disconnect()
6544
+ } catch (error) {
6545
+ /* noop */
6546
+ }
6547
+ }
6548
+ if (tapGain) {
6549
+ try {
6550
+ tapGain.disconnect()
6551
+ } catch (error) {
6552
+ /* noop */
6553
+ }
6554
+ }
6555
+ try {
6556
+ if (recorder && recorder.state === 'recording') recorder.stop()
6557
+ } catch (error) {
6558
+ /* noop */
6559
+ }
6560
+ if (stream) {
6561
+ var tracks = stream.getTracks ? stream.getTracks() : []
6562
+ for (var i = 0; i < tracks.length; i += 1) {
6563
+ try {
6564
+ tracks[i].stop()
6565
+ } catch (error) {
6566
+ /* noop */
6567
+ }
6568
+ }
6569
+ }
6570
+ if (context && typeof context.close === 'function') {
6571
+ try {
6572
+ context.close()
6573
+ } catch (error) {
6574
+ /* noop */
6575
+ }
6576
+ }
6577
+ return Promise.resolve()
6578
+ }
6579
+
6580
+ function dispose() {
6581
+ if (!disposal) disposal = release()
6582
+ return disposal
6583
+ }
6584
+
6585
+ /** decodeAudioData:新浏览器返回 Promise,老 Safari 只认回调 —— 两种都接住。 */
6586
+ function decode(buffer) {
6587
+ return new Promise(function (resolve, reject) {
6588
+ var pending = context.decodeAudioData(buffer, resolve, reject)
6589
+ if (pending && typeof pending.then === 'function') pending.then(resolve, reject)
6590
+ })
6591
+ }
6592
+
6593
+ return {
6594
+ /** 拿麦克风并开始录(权限弹窗可能很久,晚到的授权要能作废)。 */
6595
+ start: function () {
6596
+ var devices = navigator.mediaDevices
6597
+ if (!devices || typeof devices.getUserMedia !== 'function' || typeof window.MediaRecorder === 'undefined') {
6598
+ return Promise.reject(voiceError('unavailable'))
6599
+ }
6600
+ return devices
6601
+ .getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })
6602
+ .then(
6603
+ function (got) {
6604
+ stream = got
6605
+ if (lifetime.signal.aborted) {
6606
+ var granted = got.getTracks ? got.getTracks() : []
6607
+ for (var i = 0; i < granted.length; i += 1) granted[i].stop()
6608
+ throw voiceError('cancelled')
6609
+ }
6610
+ var Ctx = window.AudioContext || window.webkitAudioContext
6611
+ try {
6612
+ context = new Ctx()
6613
+ // 没有用户手势时浏览器会把 AudioContext 挂起:波形要动就得唤醒它
6614
+ if (context.state === 'suspended' && typeof context.resume === 'function') {
6615
+ var resumed = context.resume()
6616
+ if (resumed && typeof resumed.catch === 'function') resumed.catch(function () {})
6617
+ }
6618
+ analyser = context.createAnalyser()
6619
+ analyser.fftSize = samples.length
6620
+ var source = context.createMediaStreamSource(stream)
6621
+ source.connect(analyser)
6622
+ // 原始采样水龙头(只服务于实时字幕):
6623
+ // MediaRecorder 那条路不变,最终那份规范 WAV 还是它出的;这里多接一路,
6624
+ // 直接把麦克风的采样按 16k 攒起来 —— 切窗口时不用每个 tick 去 decode 整个 blob。
6625
+ // ScriptProcessorNode 已废弃但仍在(实测 Chrome 153 可用);没有就关掉实时层。
6626
+ try {
6627
+ if (typeof context.createScriptProcessor === 'function') {
6628
+ tap = context.createScriptProcessor(4096, 1, 1)
6629
+ tapGain = context.createGain()
6630
+ tapGain.gain.value = 0 // 静音:别把麦克风回灌到扬声器(会啸叫)
6631
+ tap.onaudioprocess = function (event) {
6632
+ var input = event && event.inputBuffer ? event.inputBuffer.getChannelData(0) : null
6633
+ if (input) acceptChunk(input)
6634
+ }
6635
+ source.connect(tap)
6636
+ tap.connect(tapGain)
6637
+ tapGain.connect(context.destination)
6638
+ }
6639
+ } catch (error) {
6640
+ tap = null
6641
+ }
6642
+ recorder = new window.MediaRecorder(stream)
6643
+ recorder.ondataavailable = function (event) {
6644
+ if (!lifetime.signal.aborted && event.data && event.data.size > 0) chunks.push(event.data)
6645
+ }
6646
+ recorder.onerror = function () {
6647
+ if (lifetime.signal.aborted) return
6648
+ dispose()
6649
+ if (onError) onError(voiceError('interrupted'))
6650
+ }
6651
+ recorder.start()
6652
+ return true
6653
+ } catch (error) {
6654
+ return dispose().then(function () {
6655
+ throw error
6656
+ })
6657
+ }
6658
+ },
6659
+ function (error) {
6660
+ if (error && error.name === 'NotAllowedError') throw voiceError('permission')
6661
+ if (error && error.name === 'NotFoundError') throw voiceError('missing')
6662
+ throw error
6663
+ },
6664
+ )
6665
+ },
6666
+
6667
+ /** 实时音量(0..1 左右):只用来驱动波形。 */
6668
+ level: function () {
6669
+ if (!analyser) return 0
6670
+ try {
6671
+ analyser.getFloatTimeDomainData(samples)
6672
+ } catch (error) {
6673
+ return 0
6674
+ }
6675
+ var sum = 0
6676
+ for (var i = 0; i < samples.length; i += 1) sum += samples[i] * samples[i]
6677
+ return Math.sqrt(sum / samples.length)
6678
+ },
6679
+
6680
+ /**
6681
+ * 结束录音 → 规范 WAV。
6682
+ * 重采样交给 OfflineAudioContext(浏览器自带的重采样质量比自己写的线性插值好得多),
6683
+ * 采样率固定 16kHz —— host 只收这一种(见 host 的 validateWave)。
6684
+ */
6685
+ stop: function (maxSeconds) {
6686
+ return new Promise(function (resolve, reject) {
6687
+ if (!recorder || !context || recorder.state !== 'recording') {
6688
+ dispose().then(function () {
6689
+ reject(voiceError('empty'))
6690
+ })
6691
+ return
6692
+ }
6693
+ var settled = false
6694
+ function giveUp(error) {
6695
+ if (settled) return
6696
+ settled = true
6697
+ dispose().then(function () {
6698
+ reject(error)
6699
+ })
6700
+ }
6701
+ recorder.onstop = function () {
6702
+ if (settled) return
6703
+ settled = true
6704
+ var blob
6705
+ try {
6706
+ var tracks = stream && stream.getTracks ? stream.getTracks() : []
6707
+ for (var i = 0; i < tracks.length; i += 1) tracks[i].stop()
6708
+ blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
6709
+ if (!blob.size) throw voiceError('empty')
6710
+ } catch (error) {
6711
+ dispose().then(function () {
6712
+ reject(error)
6713
+ })
6714
+ return
6715
+ }
6716
+ blob
6717
+ .arrayBuffer()
6718
+ .then(function (buffer) {
6719
+ return decode(buffer)
6720
+ })
6721
+ .then(function (decoded) {
6722
+ if (lifetime.signal.aborted) throw voiceError('cancelled')
6723
+ var rate = 16000
6724
+ var seconds = Math.min(decoded.duration || 0, maxSeconds)
6725
+ var frames = Math.max(1, Math.floor(seconds * rate))
6726
+ var Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext
6727
+ var offline = new Offline(1, frames, rate)
6728
+ var source = offline.createBufferSource()
6729
+ source.buffer = decoded
6730
+ source.connect(offline.destination)
6731
+ source.start()
6732
+ return offline.startRendering()
6733
+ })
6734
+ .then(function (rendered) {
6735
+ return encodeWave(rendered.getChannelData(0))
6736
+ })
6737
+ .then(resolve, function (error) {
6738
+ reject(error)
6739
+ })
6740
+ }
6741
+ recorder.onerror = function () {
6742
+ giveUp(voiceError('empty'))
6743
+ }
6744
+ try {
6745
+ recorder.stop()
6746
+ } catch (error) {
6747
+ giveUp(error)
6748
+ }
6749
+ }).then(
6750
+ function (bytes) {
6751
+ return dispose().then(function () {
6752
+ return bytes
6753
+ })
6754
+ },
6755
+ function (error) {
6756
+ return dispose().then(function () {
6757
+ throw error
6758
+ })
6759
+ },
6760
+ )
6761
+ },
6762
+
6763
+ dispose: dispose,
6764
+ /** 实时字幕的水龙头:没接上(浏览器没有 ScriptProcessor)时为 null。 */
6765
+ tap: {
6766
+ supported: function () {
6767
+ return !!tap
6768
+ },
6769
+ length: function () {
6770
+ return tapLength
6771
+ },
6772
+ loudIndex: function () {
6773
+ return tapLoudIndex
6774
+ },
6775
+ loudAt: function () {
6776
+ return tapLoudAt
6777
+ },
6778
+ read: readSamples,
6779
+ /** 自检/单测用:直接喂一块原始采样(真浏览器里由 onaudioprocess 调)。 */
6780
+ accept: acceptChunk,
6781
+ },
6782
+ }
6783
+ }
6784
+
6785
+ /** 录音中的心跳:到点自动收尾 + 记一次电平(波形另有自己的 50ms 循环)。 */
6786
+ /** 录音开始时把光标交给输入框:预览落字时光标要在末尾跳(textarea 没焦点就不显示光标)。 */
6787
+ function focusAskForRecording() {
6788
+ focusAskBox()
6789
+ }
6790
+
6791
+ function startVoiceTicker() {
6792
+ stopVoiceTicker()
6793
+ voice.ticker = setInterval(function () {
6794
+ if (voice.phase !== 'recording') return
6795
+ var elapsed = Date.now() - voice.startedAt
6796
+ var limit = voice.maxSeconds || VOICE_FALLBACK_SECONDS
6797
+ if (elapsed >= limit * 1000) {
6798
+ finishVoice()
6799
+ return
6800
+ }
6801
+ voice.lastClock = Math.floor(elapsed / 1000)
6802
+ }, 200)
6803
+ startWaveLoop()
6804
+ }
6805
+
6806
+ /** 识别出来的文字 → 输入框(插在光标处,前后补空格,光标落在末尾,接着还能打字)。 */
6807
+ function insertTranscript(text) {
6808
+ var current = String(askBox.value || '')
6809
+ var start = typeof askBox.selectionStart === 'number' && askBox.selectionStart >= 0 ? askBox.selectionStart : current.length
6810
+ var end = typeof askBox.selectionEnd === 'number' && askBox.selectionEnd >= start ? askBox.selectionEnd : start
6811
+ var before = current.slice(0, start)
6812
+ var after = current.slice(end)
6813
+ var prefix = before && !/\s$/.test(before) ? ' ' : ''
6814
+ var suffix = after && !/^\s/.test(after) ? ' ' : ''
6815
+ askBox.value = before + prefix + text + suffix + after
6816
+ var caret = (before + prefix + text).length
6817
+ try {
6818
+ askBox.setSelectionRange(caret, caret)
6819
+ } catch (error) {
6820
+ /* 桩环境没有这个方法 */
6821
+ }
6822
+ try {
6823
+ askBox.focus()
6824
+ } catch (error) {
6825
+ /* noop */
6826
+ }
6827
+ voice.lastText = text
6828
+ refreshAskState()
6829
+ return askBox.value
6830
+ }
6831
+
6832
+ function failVoice(generation, error) {
6833
+ if (generation !== voice.generation) return
6834
+ voice.generation += 1
6835
+ var capture = voice.capture
6836
+ voice.capture = null
6837
+ stopVoiceTicker()
6838
+ if (capture) capture.dispose()
6839
+ // "用户取消"不是错(点了 ✕、收了小窗、插件停用都走这里),不给错误提示
6840
+ var kind = error && error.voiceKind
6841
+ if (kind === 'cancelled') {
6842
+ resetVoiceRow()
6843
+ return
6844
+ }
6845
+ // 没听清 / 中断 / 失败:留在 feedback 里给原因 + 🎤 重录(和主会话一致)
6846
+ voiceFeedback(voiceMessageOf(error), kind === 'empty' || kind === 'interrupted' ? 'warn' : 'error', 'retry')
6847
+ }
6848
+
6849
+ /** 回到 idle:工具行原样还回去。 */
6850
+ function resetVoiceRow() {
6851
+ stopVoiceTicker()
6852
+ stopLive()
6853
+ if (voice.idleTimer) clearTimeout(voice.idleTimer)
6854
+ voice.idleTimer = 0
6855
+ voice.phase = 'idle'
6856
+ voice.action = null
6857
+ while (voiceActionSlot.firstChild) voiceActionSlot.removeChild(voiceActionSlot.firstChild)
6858
+ setVoiceActivity('', {})
6859
+ paintVoice()
6860
+ }
6861
+
6862
+ // ── 实时字幕(半句预览 + 停顿定稿)──────────────────────────────────────
6863
+ //
6864
+ // 说话时字就往输入框里长:每 ~1.3s 把"还没定稿的这半句"送去识别一次,整段替换上一拍
6865
+ // 的预览;检测到 ~0.6s 静音(这句说完了)就把这句送去识别并**定稿**,之后不再变。
6866
+ // 停止(■)时仍然走原来那条"整段规范 WAV"的路,回来的结果给这一整段收口:
6867
+ // · 整段识别**以已定稿的文字开头**(正常情况)→ 只补后半句,定稿的字一个不动;
6868
+ // · 对不上(整段上下文让引擎改了前面)→ 以整段为准整块替换,并在自检里记一笔。
6869
+ // ✕ 取消 = 把这次插进去的文字整块撤掉(对应主会话那个 aria-label「丢弃识别文字」)。
6870
+ //
6871
+ // 为什么预览用**采样水龙头**而不是 MediaRecorder 的 blob:切窗口只要一段连续采样,
6872
+ // 而每个 tick 去 decode 整个 blob 是 O(总时长) 的白活(说到 60 秒就是每秒 decode 60 秒音频)。
6873
+
6874
+ /** 定稿与预览拼起来时的连接处(中文直接接,英文补一个空格)。 */
6875
+ function liveJoin(left, right) {
6876
+ if (!left) return right
6877
+ if (!right) return left
6878
+ if (/[\s]$/.test(left) || /^[\s,。;:!?、,.!?;:]/.test(right)) return left + right
6879
+ return left + ' ' + right
6880
+ }
6881
+
6882
+ /** 光标位置(拿不到就当作末尾)。 */
6883
+ function caretIndex() {
6884
+ var value = String(askBox.value || '')
6885
+ return typeof askBox.selectionStart === 'number' && askBox.selectionStart >= 0
6886
+ ? Math.min(askBox.selectionStart, value.length)
6887
+ : value.length
6888
+ }
6889
+
6890
+ /** 这一段(分隔空格 + 定稿 + 预览)在输入框里该长什么样。 */
6891
+ function liveCompose() {
6892
+ var live = voice.live
6893
+ var body = liveJoin(live.committed, live.preview)
6894
+ live.text = body ? live.separator + body : ''
6895
+ return live.text
6896
+ }
6897
+
6898
+ /**
6899
+ * 把这一段落到输入框里(原位替换)。
6900
+ * 用户在我们这段**里面**改过字就不抢他的编辑:停掉实时层,以后按"停止后再出字"走。
6901
+ */
6902
+ function liveWrite() {
6903
+ var live = voice.live
6904
+ var value = String(askBox.value || '')
6905
+ var previous = live.text
6906
+ var start = live.start
6907
+ if (previous) {
6908
+ if (value.indexOf(previous) >= 0) start = value.indexOf(previous)
6909
+ else if (start >= 0 && value.slice(start, start + previous.length) === previous) {
6910
+ /* 位置没变 */
6911
+ } else {
6912
+ live.disabled = true
6913
+ live.reason = 'edited'
6914
+ stopLive()
6915
+ return false
6916
+ }
6917
+ }
6918
+ if (start < 0) start = caretIndex()
6919
+ var end = previous ? start + previous.length : start
6920
+ var caret = typeof askBox.selectionStart === 'number' ? askBox.selectionStart : end
6921
+ // 光标原本就贴在这段文字的末尾(= 用户没在改这段,只是在看)→ 跟着最新文字走,
6922
+ // 这样光标一直在新冒出来的字后面跳;用户把光标挪到别处(或在后面打字)就原位保留。
6923
+ var atBlockEnd = previous ? caret === end : caret === start
6924
+ var next = liveCompose()
6925
+ askBox.value = value.slice(0, start) + next + value.slice(end)
6926
+ live.start = next ? start : -1
6927
+ live.text = next
6928
+ var moved = next.length - previous.length
6929
+ var nextCaret = atBlockEnd ? start + next.length : caret > end ? caret + moved : caret
6930
+ if (nextCaret < 0) nextCaret = 0
6931
+ if (nextCaret > askBox.value.length) nextCaret = askBox.value.length
6932
+ try {
6933
+ askBox.setSelectionRange(nextCaret, nextCaret)
6934
+ } catch (error) {
6935
+ /* 桩环境没有这个方法 */
6936
+ }
6937
+ refreshAskState()
6938
+ return true
6939
+ }
6940
+
6941
+
6942
+ function stopLive() {
6943
+ var live = voice.live
6944
+ live.active = false
6945
+ live.inflight = false
6946
+ if (live.timer) clearInterval(live.timer)
6947
+ live.timer = 0
6948
+ if (live.controller) {
6949
+ try {
6950
+ live.controller.abort()
6951
+ } catch (error) {
6952
+ /* noop */
6953
+ }
6954
+ }
6955
+ live.controller = null
6956
+ }
6957
+
6958
+ /** 开实时层(拿不到采样水龙头就不开:功能退回"说完点 ■ 再出字")。 */
6959
+ function startLive(capture) {
6960
+ var live = voice.live
6961
+ stopLive()
6962
+ live.committed = ''
6963
+ live.preview = ''
6964
+ live.text = ''
6965
+ live.start = -1
6966
+ live.inflight = false
6967
+ live.passes = { preview: 0, commit: 0 }
6968
+ live.rewritten = false
6969
+ live.disabled = false
6970
+ live.reason = ''
6971
+ if (!capture || !capture.tap || !capture.tap.supported()) {
6972
+ live.disabled = true
6973
+ live.reason = 'no-tap'
6974
+ return false
6975
+ }
6976
+ // 云端识别器:每一拍都是一次**付费**调用(1.3 秒一拍 ≈ 46 次/分钟),不做预览。
6977
+ // 本机模型不花钱、只占点 CPU,才是实时字幕该待的地方。
6978
+ var provider = voiceProvider(voice.catalog)
6979
+ if (provider && provider.location === 'cloud') {
6980
+ live.disabled = true
6981
+ live.reason = 'cloud'
6982
+ return false
6983
+ }
6984
+ // 我们这段文字从**当前光标处**开始长;前面不是空白就补一个分隔空格
6985
+ var value = String(askBox.value || '')
6986
+ var caret = caretIndex()
6987
+ live.separator = caret > 0 && !/\s$/.test(value.slice(0, caret)) ? ' ' : ''
6988
+ live.start = -1
6989
+ live.boundary = capture.tap.length()
6990
+ live.phraseStart = live.boundary
6991
+ live.startedAt = Date.now()
6992
+ live.tickAt = 0
6993
+ live.active = true
6994
+ live.timer = setInterval(liveTick, 200)
6995
+ return true
6996
+ }
6997
+
6998
+ /**
6999
+ * 送一段(16k 采样,[from,to))去识别。
7000
+ * **"没听清"不是故障**:停顿那几秒里窗口是纯静音,识别本来就该返回空
7001
+ *(曾经把空结果当失败计数,连挂 3 次就把整个实时层关了 —— 表现是"只跟第一句,后面不吐字")。
7002
+ * @returns 识别到的文字(可能是 '')
7003
+ */
7004
+ function liveSend(from, to) {
7005
+ var live = voice.live
7006
+ var capture = voice.capture
7007
+ if (!capture || !capture.tap) return Promise.reject(new Error('没有采样水龙头'))
7008
+ var samples = capture.tap.read(from, to)
7009
+ if (samples.length < 1600) return Promise.reject(new Error('这段太短了'))
7010
+ var controller = typeof AbortController === 'function' ? new AbortController() : null
7011
+ live.controller = controller
7012
+ return fetch(SPEECH_TRANSCRIBE, {
7013
+ method: 'POST',
7014
+ headers: { 'content-type': 'application/json' },
7015
+ body: JSON.stringify({ audioBase64: bytesToBase64(encodeWave(samples)), partial: true }),
7016
+ signal: controller ? controller.signal : undefined,
7017
+ })
7018
+ .then(function (response) {
7019
+ return response.json()
7020
+ })
7021
+ .then(function (data) {
7022
+ if (data && data.ok === true) return String(data.text || '').trim()
7023
+ if (data && data.code === 'empty-transcript') return ''
7024
+ throw new Error(String((data && data.error) || '识别失败'))
7025
+ })
7026
+ }
7027
+
7028
+ /** 一句话说完了(静音够久):把它送去识别并定稿。 */
7029
+ function liveCommit(boundary) {
7030
+ var live = voice.live
7031
+ var from = live.phraseStart
7032
+ var to = Math.min(boundary, from + 16000 * VOICE_COMMIT_MAX_SECONDS)
7033
+ if (to - from < 1600) return
7034
+ live.inflight = true
7035
+ live.passes.commit += 1
7036
+ liveSend(from, to)
7037
+ .then(function (text) {
7038
+ if (!live.active) return
7039
+ live.previewFails = 0
7040
+ if (text) {
7041
+ live.committed = liveJoin(live.committed, text)
7042
+ live.preview = ''
7043
+ liveWrite()
7044
+ }
7045
+ live.boundary = to
7046
+ live.phraseStart = to
7047
+ })
7048
+ .catch(function () {
7049
+ /* 这一句没转出来:不打断录音,等停止时整段再试一次 */
7050
+ })
7051
+ .then(function () {
7052
+ live.inflight = false
7053
+ live.controller = null
7054
+ })
7055
+ }
7056
+
7057
+ /**
7058
+ * 半句预览:把"最后 10 秒还没定稿的那半句"送去识别,整段替换上一拍的预览。
7059
+ * @returns 这一拍真的发出去了没有(没发就不该吃掉节流窗口 —— 否则录到 0.2 秒时那次空转
7060
+ * 会把第一拍预览推到 1.5 秒之后,用户会觉得"怎么半天不出字")。
7061
+ */
7062
+ function livePreview() {
7063
+ var live = voice.live
7064
+ var capture = voice.capture
7065
+ if (!capture || !capture.tap) return false
7066
+ // 上次定稿之后还没开口(一直是静音)→ 不浪费调用,也别把上一拍预览抹掉
7067
+ var loudIndex = capture.tap.loudIndex()
7068
+ if (loudIndex < 0 || loudIndex <= live.phraseStart) return false
7069
+ var length = capture.tap.length()
7070
+ var from = Math.max(live.phraseStart, length - 16000 * VOICE_PREVIEW_WINDOW)
7071
+ if (length - from < 16000 * 0.5) return false
7072
+ live.inflight = true
7073
+ live.passes.preview += 1
7074
+ liveSend(from, length)
7075
+ .then(function (text) {
7076
+ if (!live.active) return
7077
+ live.previewFails = 0
7078
+ if (text === live.preview) return
7079
+ // 这一拍没识别到内容(窗口里正好都是静音):留着上一拍的预览,别闪没
7080
+ if (!text) return
7081
+ live.preview = text
7082
+ liveWrite()
7083
+ })
7084
+ .catch(function (error) {
7085
+ if (!live.active) return
7086
+ // 预览连续失败就不再打扰用户(停止时那条路仍然会出字);
7087
+ // 阈值给宽一点:偶发一次超时不该把实时字幕整个关掉
7088
+ if (error && error.name === 'AbortError') return
7089
+ live.previewFails = (live.previewFails || 0) + 1
7090
+ if (live.previewFails >= 5) {
7091
+ live.disabled = true
7092
+ live.reason = 'preview-failed'
7093
+ stopLive()
7094
+ }
7095
+ })
7096
+ .then(function () {
7097
+ live.inflight = false
7098
+ live.controller = null
7099
+ })
7100
+ return true
7101
+ }
7102
+
7103
+ /** 实时层的节拍:先看有没有"一句话说完了",再看要不要刷半句预览。 */
7104
+ function liveTick() {
7105
+ var live = voice.live
7106
+ if (!live.active || live.disabled || voice.phase !== 'recording') return
7107
+ var capture = voice.capture
7108
+ if (!capture || !capture.tap || !capture.tap.supported()) return
7109
+ if (live.inflight) return
7110
+ var now = Date.now()
7111
+ var length = capture.tap.length()
7112
+ var loudIndex = capture.tap.loudIndex()
7113
+ var loudAt = capture.tap.loudAt()
7114
+ // ① 停顿定稿:最近一次有声已经过去 600ms 以上,且这句够长
7115
+ if (loudIndex >= 0 && now - loudAt >= VOICE_PAUSE_MS && now - loudAt < 5000) {
7116
+ var boundary = Math.min(length, loudIndex + Math.round(16000 * 0.25))
7117
+ if (boundary - live.phraseStart >= (16000 * VOICE_PAUSE_MIN_MS) / 1000) {
7118
+ live.pendingReady = true
7119
+ liveCommit(boundary)
7120
+ return
7121
+ }
7122
+ }
7123
+ // ② 半句预览:每 1.3 秒刷一次(只有真发出去才算用掉这一拍)
7124
+ if (now - live.tickAt < VOICE_PREVIEW_EVERY) return
7125
+ if (livePreview()) live.tickAt = now
7126
+ }
7127
+
7128
+ /**
7129
+ * ■ 停止后的收口:整段识别已经拿到 fullText,把它和已经插进去的那段对齐。
7130
+ * 正常情况(整段以定稿开头)只补后半句;对不上就整块替换(以整段为准)。
7131
+ */
7132
+ function liveFinalize(fullText) {
7133
+ var live = voice.live
7134
+ var committed = live.committed
7135
+ var text = String(fullText || '').trim()
7136
+ stopLive()
7137
+ if (!text) {
7138
+ // 整段一个都没识别出来:已经定稿的部分留着(用户看得见),预览丢掉
7139
+ live.preview = ''
7140
+ liveWrite()
7141
+ return live.committed
7142
+ }
7143
+ var merged = text
7144
+ if (committed) {
7145
+ var rest = liveTailAfterCommitted(committed, text)
7146
+ if (rest === null) {
7147
+ live.rewritten = true
7148
+ merged = text
7149
+ } else {
7150
+ merged = liveJoin(committed, rest)
7151
+ }
7152
+ }
7153
+ live.committed = merged
7154
+ live.preview = ''
7155
+ liveWrite()
7156
+ return merged
7157
+ }
7158
+
7159
+ /**
7160
+ * 整段识别里"已定稿那部分"之后还剩什么。
7161
+ * 归一化(去空白与标点)之后比前缀:对得上 → 返回剩余原文;对不上 → null。
7162
+ */
7163
+ function liveTailAfterCommitted(committed, full) {
7164
+ var norm = function (text) {
7165
+ var out = []
7166
+ for (var i = 0; i < text.length; i += 1) {
7167
+ var ch = text[i]
7168
+ if (/[\s,。;:!?、,.!?;:'"()()\[\]【】…—-]/.test(ch)) continue
7169
+ out.push({ ch: ch, at: i })
7170
+ }
7171
+ return out
7172
+ }
7173
+ var want = norm(committed)
7174
+ var got = norm(full)
7175
+ if (!want.length || got.length < want.length) return null
7176
+ for (var i = 0; i < want.length; i += 1) {
7177
+ if (want[i].ch !== got[i].ch) return null
7178
+ }
7179
+ return full.slice(got[want.length - 1].at + 1).trim()
7180
+ }
7181
+
7182
+ /** 开始一段录音(先问 host 要目录:能不能录、最长多久)。 */
7183
+ function startVoice() {
7184
+ if (!recordingSupported()) {
7185
+ voiceFeedback(VOICE_ERRORS.unavailable, 'error', null)
7186
+ return
7187
+ }
7188
+ var generation = ++voice.generation
7189
+ voice.phase = 'requesting'
7190
+ setVoiceActivity('请允许使用麦克风…', { dot: true })
7191
+ setVoiceAction(null)
7192
+ paintVoice()
7193
+ fetchSpeechCatalog(true)
7194
+ .then(function (catalog) {
7195
+ if (generation !== voice.generation) return null
7196
+ if (!catalog || catalog.available !== true) {
7197
+ var provider = voiceProvider(catalog)
7198
+ var reason = catalog ? catalog.reason : ''
7199
+ var text =
7200
+ reason === 'unprepared' || (provider && provider.phase !== 'ready' && provider.phase !== 'standby')
7201
+ ? '语音模型还没准备好' + (provider && provider.name ? '(' + provider.name + ')' : '')
7202
+ : catalog && catalog.error
7203
+ ? catalog.error
7204
+ : '这个部署没有可用的语音识别服务'
7205
+ // 有救的(模型没装)给一个「准备模型」按钮:下载在 host 上跑,几百 MB 到 1G,得用户自己点
7206
+ voiceFeedback(text, 'warn', provider ? 'prepare' : 'retry')
7207
+ return null
7208
+ }
7209
+ var limits = catalog.limits || {}
7210
+ voice.maxSeconds = clamp(Number(limits.maxSeconds) || VOICE_FALLBACK_SECONDS, 5, 600)
7211
+ var capture = createCapture(function (error) {
7212
+ failVoice(generation, error)
7213
+ })
7214
+ voice.capture = capture
7215
+ return capture.start().then(function () {
7216
+ if (generation !== voice.generation) {
7217
+ capture.dispose()
7218
+ return null
7219
+ }
7220
+ voice.phase = 'recording'
7221
+ voice.startedAt = Date.now()
7222
+ resetWave()
7223
+ setVoiceActivity('', {})
7224
+ paintVoice()
7225
+ // 光标交给输入框:实时字幕落字时要看见光标在末尾跳(textarea 没焦点就不画光标)
7226
+ focusAskForRecording()
7227
+ startVoiceTicker()
7228
+ // 实时字幕:说话时字就往输入框里长(拿不到采样水龙头就自动退回"停止后出字")
7229
+ startLive(capture)
7230
+ return true
7231
+ })
7232
+ })
7233
+ .catch(function (error) {
7234
+ failVoice(generation, error)
7235
+ })
7236
+ }
7237
+
7238
+ /** 结束录音 → 送去识别 → 文字插进输入框。 */
7239
+ function finishVoice() {
7240
+ if (voice.phase !== 'recording') return
7241
+ var generation = voice.generation
7242
+ var capture = voice.capture
7243
+ var maxSeconds = voice.maxSeconds || VOICE_FALLBACK_SECONDS
7244
+ stopVoiceTicker()
7245
+ voice.phase = 'transcribing'
7246
+ setVoiceActivity('识别中…', { dot: true })
7247
+ setVoiceAction(null)
7248
+ paintVoice()
7249
+ var abort = typeof AbortController === 'function' ? new AbortController() : null
7250
+ voice.abort = abort
7251
+ capture
7252
+ .stop(maxSeconds)
7253
+ .then(function (bytes) {
7254
+ if (generation !== voice.generation) return null
7255
+ if (!bytes || !bytes.length) throw voiceError('empty')
7256
+ if (bytes.length > VOICE_MAX_BYTES) throw new Error('录音超过服务限制,请缩短录音后重试。')
7257
+ return fetch(SPEECH_TRANSCRIBE, {
7258
+ method: 'POST',
7259
+ headers: { 'content-type': 'application/json' },
7260
+ body: JSON.stringify({ audioBase64: bytesToBase64(bytes) }),
7261
+ signal: abort ? abort.signal : undefined,
7262
+ })
7263
+ })
7264
+ .then(function (response) {
7265
+ if (generation !== voice.generation || !response) return null
7266
+ return response.json().then(function (data) {
7267
+ if (generation !== voice.generation) return null
7268
+ if (!data || data.ok !== true) {
7269
+ var error = String((data && data.error) || '识别失败')
7270
+ if (data && data.code === 'unprepared') {
7271
+ voiceFeedback(error, 'warn', 'prepare')
7272
+ } else if (data && data.code === 'empty-transcript') {
7273
+ voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
7274
+ } else {
7275
+ voiceFeedback('语音识别失败:' + error, 'error', 'retry')
7276
+ }
7277
+ return null
7278
+ }
7279
+ var text = String(data.text || '').trim()
7280
+ var liveHasText = voice.live.active && !!(voice.live.committed || voice.live.preview || voice.live.text)
7281
+ if (!text) {
7282
+ if (liveHasText) {
7283
+ // 整段没转出来,但实时那几段已经落进输入框了:留着他看得见的那份
7284
+ voice.lastText = liveFinalize('')
7285
+ voice.capture = null
7286
+ voice.abort = null
7287
+ resetVoiceRow()
7288
+ return null
7289
+ }
7290
+ voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
7291
+ return null
7292
+ }
7293
+ if (liveHasText) {
7294
+ // 实时字幕已经在输入框里写了字:用它给整段收口(定稿部分尽量不动)
7295
+ voice.lastText = liveFinalize(text)
7296
+ } else {
7297
+ insertTranscript(text)
7298
+ }
7299
+ // 插入成功 = **直接回 idle**:文字就摆在上面那行,不必再报一次
7300
+ //(主会话也是这样:成功不额外说话,录音行收起、🎤 回位)。
7301
+ voice.capture = null
7302
+ voice.abort = null
7303
+ resetVoiceRow()
7304
+ return text
7305
+ })
7306
+ })
7307
+ .catch(function (error) {
7308
+ if (generation !== voice.generation) return
7309
+ // 用户点了 ✕ / 收起小窗:静默(这不是失败,是他自己不要了)
7310
+ if (error && error.name === 'AbortError') return
7311
+ var kind = error && error.voiceKind
7312
+ if (kind === 'cancelled') return
7313
+ if (kind === 'empty') {
7314
+ voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
7315
+ return
7316
+ }
7317
+ voiceFeedback('语音识别失败:' + voiceMessageOf(error), 'error', 'retry')
7318
+ })
7319
+ .then(function () {
7320
+ if (generation !== voice.generation) return
7321
+ voice.capture = null
7322
+ voice.abort = null
7323
+ })
7324
+ }
7325
+
7326
+ /**
7327
+ * 取消当前这一轮(录音或识别)→ 回到 idle。✕ / Esc / 关面板 / 停用插件都走它。
7328
+ * 一定会松开麦克风、并且让晚到的回调全部作废(generation + 1)。
7329
+ */
7330
+ function cancelVoice(options) {
7331
+ if (!voice) return
7332
+ var opts = typeof options === 'string' ? { note: options } : options || {}
7333
+ var capture = voice.capture
7334
+ voice.generation += 1
7335
+ voice.capture = null
7336
+ if (voice.abort) {
7337
+ try {
7338
+ voice.abort.abort()
7339
+ } catch (error) {
7340
+ /* noop */
7341
+ }
7342
+ }
7343
+ voice.abort = null
7344
+ if (voice.pollTimer) clearTimeout(voice.pollTimer)
7345
+ voice.pollTimer = 0
7346
+ // 取消 = 只停(不再更新那段实时文字),**已经说出来的字一律留着**。
7347
+ // 早先跟着主会话的 ✕ 做过"丢弃识别文字",实际用起来是"我按了取消,字没了" ——
7348
+ // 录到一半反悔时更常见的诉求是"留着我已经说出来的",所以统一成都留。
7349
+ stopLive()
7350
+ if (capture) capture.dispose()
7351
+ resetVoiceRow()
7352
+ // 少数情况下要交代一句(例如"这条消息先发出去了"):留在 feedback 里,2 秒后自己回 idle
7353
+ if (opts.note) {
7354
+ voiceFeedback(opts.note, '', null)
7355
+ voice.idleTimer = setTimeout(function () {
7356
+ if (voice.phase !== 'feedback') return
7357
+ resetVoiceRow()
7358
+ }, 2200)
7359
+ }
7360
+ }
7361
+
7362
+ /** 让 host 去准备识别模型(首次要下载);下载在 host 上跑,关掉面板也继续。 */
7363
+ function prepareVoice() {
7364
+ voice.phase = 'transcribing' // 复用"进行中"的形态:✕ + 呼吸点 + 文案
7365
+ setVoiceActivity('正在准备语音模型…', { dot: true })
7366
+ setVoiceAction(null)
7367
+ paintVoice()
7368
+ fetch(SPEECH_PREPARE, {
7369
+ method: 'POST',
7370
+ headers: { 'content-type': 'application/json' },
7371
+ body: JSON.stringify({}),
7372
+ })
7373
+ .then(function (response) {
7374
+ return response.json()
7375
+ })
7376
+ .then(function (data) {
7377
+ if (!data || data.ok !== true) {
7378
+ voiceFeedback('准备失败:' + ((data && data.error) || '未知原因'), 'error', 'prepare')
7379
+ return
7380
+ }
7381
+ voice.catalog = data.catalog || null
7382
+ voice.catalogAt = 0 // 状态变了,下一次点击重新问 host
7383
+ pollPrepare(0)
7384
+ })
7385
+ .catch(function (error) {
7386
+ voiceFeedback('准备失败:' + voiceMessageOf(error), 'error', 'prepare')
7387
+ })
7388
+ }
7389
+
7390
+ /** 轮询准备进度(下载在 host 上,这里只是播报)。 */
7391
+ function pollPrepare(round) {
7392
+ if (round > 480) return // 最多看 ~12 分钟,之后交给用户自己再来
7393
+ if (voice.pollTimer) clearTimeout(voice.pollTimer)
7394
+ voice.pollTimer = setTimeout(function () {
7395
+ fetchSpeechCatalog(true)
7396
+ .then(function (catalog) {
7397
+ if (catalog && catalog.available) {
7398
+ voiceFeedback('语音模型已就绪,可以开始说话', '', 'retry')
7399
+ return
7400
+ }
7401
+ var provider = voiceProvider(catalog)
7402
+ if (provider && provider.phase === 'failed') {
7403
+ voiceFeedback('准备失败:' + (provider.message || '模型下载出错'), 'error', 'prepare')
7404
+ return
7405
+ }
7406
+ var total = provider && provider.totalBytes ? provider.totalBytes : 0
7407
+ var done = provider && provider.completedBytes ? provider.completedBytes : 0
7408
+ var progress = total > 0 ? '(' + Math.round((done / total) * 100) + '%)' : ''
7409
+ setVoiceActivity('正在准备语音模型' + progress + '…(可以先去忙别的,下载在后台继续)', { dot: true })
7410
+ voice.phase = 'transcribing'
7411
+ paintVoice()
7412
+ pollPrepare(round + 1)
7413
+ })
7414
+ .catch(function () {
7415
+ pollPrepare(round + 1)
7416
+ })
7417
+ }, 1500)
7418
+ }
7419
+
7420
+ var offMic = listen(micButton, 'click', function (event) {
7421
+ event.stopPropagation()
7422
+ startVoice()
7423
+ })
7424
+ // 切走这个窗口(换到别的 App)就停止录音 —— 主会话也是这么做的:
7425
+ // 人已经不在说话了,继续占着麦克风只会录一串环境噪音。识别**不打断**(那时音频已经拿到了)。
7426
+ var offVoiceBlur = listen(window, 'blur', function () {
7427
+ if (voice.phase === 'recording') cancelVoice()
7428
+ })
7429
+ var offVoiceCancel = listen(voiceCancel, 'click', function (event) {
7430
+ event.stopPropagation()
7431
+ // feedback 阶段点 ✕ 只是"关掉这条提示",其余阶段是取消这次录音/识别。
7432
+ // 取消**不删字**:已经说出来的(含半句预览)一律留在输入框里。
7433
+ cancelVoice()
7434
+ })
7435
+ var offVoiceStop = listen(voiceStop, 'click', function (event) {
7436
+ event.stopPropagation()
7437
+ finishVoice()
7438
+ })
7439
+ paintVoice()
7440
+ // ═════════════════════════ 语音输入(结束) ═════════════════════════
7441
+
5116
7442
  // 用户自己滚消息区(滚轮/触摸)→ 不再强行把他拉回底部
5117
7443
  var offBodyWheel = listen(body, 'wheel', noteUserScroll)
5118
7444
  var offBodyTouch = listen(body, 'touchmove', noteUserScroll)
@@ -5168,7 +7494,139 @@ window.__ModuleLoader__.load({
5168
7494
  turns: function () {
5169
7495
  return state.turns.slice()
5170
7496
  },
7497
+ /** 自检用:当前待发送的引用(引用区里那几张卡片)。 */
7498
+ quotes: function () {
7499
+ return state.quotes.map(function (quote) {
7500
+ return {
7501
+ id: quote.id,
7502
+ label: quote.label,
7503
+ text: quote.text,
7504
+ context: quote.context || '',
7505
+ contextChars: (quote.context || '').length,
7506
+ session: quote.session === true,
7507
+ }
7508
+ })
7509
+ },
7510
+ /** 自检用:手工加一段引用(等价于点了「❝ 引用」/「引用整条」)。 */
7511
+ quote: function (text, label, extra) {
7512
+ return addQuote(text, label || '调试', extra || {})
7513
+ },
7514
+ /** 自检用:某个节点上的选区会被标成什么出处(等价于点引用那一刻算出来的那个)。 */
7515
+ quoteLabel: function (node) {
7516
+ return node ? panelSourceLabel(node) : '(需要传节点)'
7517
+ },
7518
+ /** 自检用:引用 + 提问拼出来的那条消息原文(发给模型的就是它)。 */
7519
+ compose: function (quotes, question) {
7520
+ return composeQuestion(quotes || state.quotes, question)
7521
+ },
7522
+ /** 自检用:引用浮标与最近一次可引用选区的状态。 */
7523
+ quoteState: function () {
7524
+ return {
7525
+ visible: quoteButton.style.display === 'inline-flex',
7526
+ selection: state.quoteSelection
7527
+ ? {
7528
+ text: state.quoteSelection.text,
7529
+ label: state.quoteSelection.label,
7530
+ source: state.quoteSelection.source,
7531
+ turnIndex: state.quoteSelection.turnIndex === undefined ? -1 : state.quoteSelection.turnIndex,
7532
+ hasRange: !!state.quoteSelection.range,
7533
+ }
7534
+ : null,
7535
+ panelOpen: panelOpen,
7536
+ resolving: state.resolvingQuotes === true,
7537
+ }
7538
+ },
7539
+ /** 自检用:引用浮标的节点(无头环境里页面可能挂了两棵树,用这个拿真正带监听的那个)。 */
7540
+ quoteNode: function () {
7541
+ return quoteButton
7542
+ },
5171
7543
  close: closePanel,
7544
+ /**
7545
+ * 自检用:语音输入的状态(阶段 / 提示 / 目录 / 最近一次转写)。
7546
+ * phase:idle / requesting / recording / transcribing / done
7547
+ */
7548
+ voice: function () {
7549
+ return {
7550
+ phase: voice.phase,
7551
+ supported: recordingSupported(),
7552
+ /** 录音行出来了没有(出来了 = 工具行换成了 ✕ / 波形 / ■ 那一套)。 */
7553
+ capture: captureRow.getAttribute('data-show') === '1',
7554
+ /** 状态文案(请允许使用麦克风… / 识别中… / 出错原因)。 */
7555
+ activity: voiceActivityText.textContent,
7556
+ activityTone: voiceActivity.getAttribute('data-tone') || '',
7557
+ dot: voiceDot.style.display !== 'none',
7558
+ waveform: waveSvg.style.display !== 'none',
7559
+ stop: voiceStop.style.display !== 'none',
7560
+ /** 行内动作:'prepare'(准备模型)/ 'retry'(🎤 重录)/ ''。 */
7561
+ action: voice.action || '',
7562
+ maxSeconds: voice.maxSeconds || VOICE_FALLBACK_SECONDS,
7563
+ level: Number(voice.level.toFixed(3)),
7564
+ lastText: voice.lastText,
7565
+ /** 波形 80 根线的高度(自检/冒烟用:静音时全是 2px 的基线)。 */
7566
+ bars: waveBars.map(function (bar) {
7567
+ return Number(bar.node.getAttribute('y2')) - Number(bar.node.getAttribute('y1'))
7568
+ }),
7569
+ /** 实时字幕:定稿 / 半句预览 / 拍数 / 有没有被整段识别改写过。 */
7570
+ live: {
7571
+ active: voice.live.active,
7572
+ disabled: voice.live.disabled,
7573
+ reason: voice.live.reason,
7574
+ committed: voice.live.committed,
7575
+ preview: voice.live.preview,
7576
+ text: voice.live.text,
7577
+ passes: { preview: voice.live.passes.preview, commit: voice.live.passes.commit },
7578
+ rewritten: voice.live.rewritten,
7579
+ boundary: voice.live.boundary,
7580
+ phraseStart: voice.live.phraseStart,
7581
+ tap: voice.capture && voice.capture.tap && voice.capture.tap.supported()
7582
+ ? {
7583
+ length: voice.capture.tap.length(),
7584
+ loudIndex: voice.capture.tap.loudIndex(),
7585
+ loudAt: voice.capture.tap.loudAt(),
7586
+ }
7587
+ : null,
7588
+ },
7589
+ catalog: voice.catalog
7590
+ ? {
7591
+ available: voice.catalog.available === true,
7592
+ reason: voice.catalog.reason || '',
7593
+ error: voice.catalog.error || '',
7594
+ providers: (voice.catalog.providers || []).map(function (provider) {
7595
+ return { id: provider.id, name: provider.name, phase: provider.phase }
7596
+ }),
7597
+ }
7598
+ : null,
7599
+ }
7600
+ },
7601
+ /** 自检用:麦克风按钮节点(无头环境里页面可能挂了两棵树,用这个拿真正带监听的那个)。 */
7602
+ voiceNode: function () {
7603
+ return micButton
7604
+ },
7605
+ /** 自检用:手动催一拍实时字幕(测试里不用干等 1.3s 的节流)。 */
7606
+ voiceTick: function () {
7607
+ voice.live.tickAt = 0
7608
+ liveTick()
7609
+ return true
7610
+ },
7611
+ /** 自检用:录音行的各部件(✕ / 波形 / 状态 / 动作位 / ■)。 */
7612
+ voiceNodes: function () {
7613
+ return {
7614
+ row: captureRow,
7615
+ cancel: voiceCancel,
7616
+ wave: waveSvg,
7617
+ activity: voiceActivity,
7618
+ action: voiceActionSlot,
7619
+ stop: voiceStop,
7620
+ }
7621
+ },
7622
+ /** 自检用:读/写追问输入框(验证"转写文字插进输入框")。 */
7623
+ askValue: function (text) {
7624
+ if (text !== undefined) {
7625
+ askBox.value = String(text)
7626
+ refreshAskState()
7627
+ }
7628
+ return askBox.value
7629
+ },
5172
7630
  /** 自检用:清掉本地缓存,模拟"刷新页面后重开"(验证历史回放路径)。 */
5173
7631
  forget: function () {
5174
7632
  cache.clear()
@@ -5344,6 +7802,7 @@ window.__ModuleLoader__.load({
5344
7802
  state.bridgeActive = false
5345
7803
  state.parts = { translation: '', detail: '' }
5346
7804
  state.turns = []
7805
+ clearQuotes()
5347
7806
  state.raw = ''
5348
7807
  state.phase = 'idle'
5349
7808
  state.error = ''
@@ -5373,11 +7832,14 @@ window.__ModuleLoader__.load({
5373
7832
  }
5374
7833
  return function () {
5375
7834
  offPopEnd()
7835
+ offQuotePopEnd()
5376
7836
  offMouseUp()
5377
7837
  offKeyUp()
5378
7838
  offMouseDown()
5379
7839
  offButtonDown()
5380
7840
  offButtonClick()
7841
+ offQuoteDown()
7842
+ offQuoteClick()
5381
7843
  offScroll()
5382
7844
  offResize()
5383
7845
  offKeyDown()
@@ -5387,6 +7849,12 @@ window.__ModuleLoader__.load({
5387
7849
  offAskKey()
5388
7850
  offAskInput()
5389
7851
  offAskSend()
7852
+ // 语音输入:摘掉麦克风监听,并把正在录/正在识别的这一轮彻底作废(松开麦克风)
7853
+ offMic()
7854
+ offVoiceCancel()
7855
+ offVoiceStop()
7856
+ offVoiceBlur()
7857
+ cancelVoice()
5390
7858
  offClose()
5391
7859
  offDrag()
5392
7860
  offPillDown()
@@ -5435,11 +7903,13 @@ window.__ModuleLoader__.load({
5435
7903
  })
5436
7904
  bridged.clear()
5437
7905
  state.turns = []
7906
+ state.quotes = []
5438
7907
  if (state.request) state.request.abort()
5439
7908
  for (var i = 0; i < timers.length; i++) clearTimeout(timers[i])
5440
7909
  cache.clear()
5441
7910
  if (layer.parentNode) layer.parentNode.removeChild(layer)
5442
7911
  if (button.parentNode) button.parentNode.removeChild(button)
7912
+ if (quoteButton.parentNode) quoteButton.parentNode.removeChild(quoteButton)
5443
7913
  if (panel.parentNode) panel.parentNode.removeChild(panel)
5444
7914
  if (pill.parentNode) pill.parentNode.removeChild(pill)
5445
7915
  if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
@@ -5597,6 +8067,66 @@ window.__ModuleLoader__.load({
5597
8067
  return svg
5598
8068
  }
5599
8069
 
8070
+ /** 取消图标(✕):两条斜线,和主会话录音行的取消键同形。 */
8071
+ function closeIcon() {
8072
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8073
+ svg.setAttribute('viewBox', '0 0 16 16')
8074
+ svg.setAttribute('fill', 'none')
8075
+ svg.setAttribute('stroke', 'currentColor')
8076
+ svg.setAttribute('stroke-width', '1.8')
8077
+ svg.setAttribute('stroke-linecap', 'round')
8078
+ svg.setAttribute('aria-hidden', 'true')
8079
+ var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8080
+ path.setAttribute('d', 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6')
8081
+ svg.appendChild(path)
8082
+ return svg
8083
+ }
8084
+
8085
+ /**
8086
+ * 麦克风图标:话筒头(圆角矩形)+ 底座弧 + 支架。
8087
+ * 用描边而不是实心:录音时这个位置要换成"红色实心停止键",
8088
+ * 平时轻一点,红起来的那一刻对比才够强。
8089
+ */
8090
+ function micIcon() {
8091
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8092
+ svg.setAttribute('viewBox', '0 0 16 16')
8093
+ svg.setAttribute('width', '14')
8094
+ svg.setAttribute('height', '14')
8095
+ svg.setAttribute('fill', 'none')
8096
+ svg.setAttribute('stroke', 'currentColor')
8097
+ svg.setAttribute('stroke-width', '1.5')
8098
+ svg.setAttribute('stroke-linecap', 'round')
8099
+ svg.setAttribute('aria-hidden', 'true')
8100
+ var cap = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8101
+ cap.setAttribute('d', 'M8 1.9a2.1 2.1 0 0 1 2.1 2.1v3.4a2.1 2.1 0 0 1-4.2 0V4A2.1 2.1 0 0 1 8 1.9z')
8102
+ svg.appendChild(cap)
8103
+ var arc = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8104
+ arc.setAttribute('d', 'M3.6 7.2v.5a4.4 4.4 0 0 0 8.8 0v-.5')
8105
+ svg.appendChild(arc)
8106
+ var stem = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8107
+ stem.setAttribute('d', 'M8 12.1v2')
8108
+ svg.appendChild(stem)
8109
+ return svg
8110
+ }
8111
+
8112
+ /** 转圈图标(权限请求中 / 识别中):CSS 让它转起来。 */
8113
+ function spinnerIcon() {
8114
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8115
+ svg.setAttribute('viewBox', '0 0 16 16')
8116
+ svg.setAttribute('width', '14')
8117
+ svg.setAttribute('height', '14')
8118
+ svg.setAttribute('fill', 'none')
8119
+ svg.setAttribute('stroke', 'currentColor')
8120
+ svg.setAttribute('stroke-width', '1.7')
8121
+ svg.setAttribute('stroke-linecap', 'round')
8122
+ svg.setAttribute('aria-hidden', 'true')
8123
+ var arc = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8124
+ // 缺一个口的圆:转起来才看得出在动
8125
+ arc.setAttribute('d', 'M8 1.8a6.2 6.2 0 1 1-4.4 1.8')
8126
+ svg.appendChild(arc)
8127
+ return svg
8128
+ }
8129
+
5600
8130
  /** 发送图标(上箭头,和主会话 composer 一致)。 */
5601
8131
  function sendIcon() {
5602
8132
  var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
@@ -5629,8 +8159,33 @@ window.__ModuleLoader__.load({
5629
8159
  return svg
5630
8160
  }
5631
8161
 
8162
+ /**
8163
+ * 引用浮标上的图标:两个引号块(❝ 的简化画法)。
8164
+ * 用圆 + 左下角甩出的小尾巴拼 —— 13px 下也认得出,不依赖系统有没有那个字形。
8165
+ */
8166
+ function quoteIcon() {
8167
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8168
+ svg.setAttribute('viewBox', '0 0 16 16')
8169
+ svg.setAttribute('fill', 'currentColor')
8170
+ svg.setAttribute('aria-hidden', 'true')
8171
+ var marks = [
8172
+ { cx: 5.4, cy: 6.1, tail: 'M3.9 7.4 2.1 12.2 6.1 10.2z' },
8173
+ { cx: 11.6, cy: 6.1, tail: 'M10.1 7.4 8.3 12.2 12.3 10.2z' },
8174
+ ]
8175
+ for (var i = 0; i < marks.length; i += 1) {
8176
+ var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
8177
+ dot.setAttribute('cx', String(marks[i].cx))
8178
+ dot.setAttribute('cy', String(marks[i].cy))
8179
+ dot.setAttribute('r', '2.3')
8180
+ svg.appendChild(dot)
8181
+ var tail = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8182
+ tail.setAttribute('d', marks[i].tail)
8183
+ svg.appendChild(tail)
8184
+ }
8185
+ return svg
8186
+ }
8187
+
5632
8188
  exports.apply = apply
5633
8189
  exports.inject = ['slots']
5634
8190
  return module.exports
5635
- },
5636
- })
8191
+ },})