@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.
- package/README.md +99 -4
- package/assets/quotes.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice.png +0 -0
- package/lib/client.js +2623 -68
- package/lib/index.js +525 -2
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +5 -0
- package/lib/prompts.js.map +1 -1
- package/lib/types/index.d.ts +56 -0
- package/package.json +6 -3
- package/scripts/build-demo-pages.mjs +58 -4
- package/scripts/build.sh +4 -2
- package/scripts/measure-cost.mjs +251 -0
- package/scripts/smoke-panel.mjs +481 -0
- package/scripts/smoke-voice.mjs +772 -0
- package/scripts/test-client.mjs +1357 -0
- package/scripts/test-prompt.mjs +15 -0
- package/scripts/test-speech.mjs +220 -0
- package/scripts/test-transcript.mjs +74 -1
- package/src/client/index.js +2623 -68
- package/src/index.ts +607 -2
- package/src/prompts.ts +5 -0
package/src/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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)
|
|
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 (
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
*
|
|
2912
|
-
*
|
|
3710
|
+
* 三个克制的地方:
|
|
3711
|
+
* · 等输入框真的可见才聚焦(首轮翻译还在跑时它是藏着的,聚焦藏着的元素没意义);
|
|
3712
|
+
* · 用户已经在别处打字(主会话输入框、侧边栏…)就**不抢**焦点,并就此作罢;
|
|
3713
|
+
* · preventScroll:别因为聚焦把页面滚一下(划完词页面跳走最难受)。
|
|
2913
3714
|
*/
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
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
|
-
|
|
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++)
|
|
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'
|
|
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
|
|
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')
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5057
|
-
//
|
|
5058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
},})
|