@yfwu2020/dsh-selection-explain 0.1.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -7
- package/assets/quotes.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +3453 -203
- package/lib/index.js +533 -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 +76 -0
- package/package.json +8 -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-bridge.mjs +365 -0
- package/scripts/smoke-panel.mjs +433 -0
- package/scripts/smoke-voice.mjs +772 -0
- package/scripts/test-bridge.mjs +408 -0
- package/scripts/test-client.mjs +1562 -1
- 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 +3453 -203
- package/src/index.ts +625 -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;',
|
|
@@ -1276,6 +1474,359 @@ window.__ModuleLoader__.load({
|
|
|
1276
1474
|
return String(text || '').replace(/\s+/g, ' ').trim()
|
|
1277
1475
|
}
|
|
1278
1476
|
|
|
1477
|
+
// ────────────────────── 侧边栏网页:iframe 划词桥 ──────────────────────
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* 侧边栏的 HTML 预览是**不透明源**沙箱 iframe(`sandbox="allow-scripts"`,
|
|
1481
|
+
* 刻意不给 `allow-same-origin`):父页面拿不到 contentDocument,
|
|
1482
|
+
* 顶层 `window.getSelection()` 永远是空的 —— 划词在那里等于瞎的。
|
|
1483
|
+
*
|
|
1484
|
+
* 解法只有一个方向:**让帧内自己上报**。下面这段脚本被注入进被预览的文档,
|
|
1485
|
+
* 在帧内读选区、就地采集上下文(算法与父页面 `collectContext` 完全一致),
|
|
1486
|
+
* 再用 postMessage 报出来;父页面把"帧内坐标 + iframe 的 getBoundingClientRect()"
|
|
1487
|
+
* 换算成视口坐标,复用同一套浮标与面板。
|
|
1488
|
+
*
|
|
1489
|
+
* 安全边界(不能退让):**不加 `allow-same-origin`**,帧仍是不透明源 ——
|
|
1490
|
+
* 预览页照样读不到 GUI 的 DOM / Cookie / localStorage,也照样不能导航顶层窗口。
|
|
1491
|
+
* 桥只往外报"选中了什么 + 周围那点文本",别的什么都不给。
|
|
1492
|
+
*
|
|
1493
|
+
* 两处细节不能省:
|
|
1494
|
+
* ① 监听挂在 **window** 上、并且用轮询兜底 —— 交互式预览的外层文档是 bootstrap,
|
|
1495
|
+
* 它 `document.open()/write()/close()` 会把 document 上的监听全部冲掉,
|
|
1496
|
+
* 只有 window 上的监听能活下来(见宿主 ui-sidebar-documentpreview 的 createHtmlDocument)。
|
|
1497
|
+
* ② 报的是**帧内视口坐标**(range.getClientRects 的最后一段),父页面再加偏移。
|
|
1498
|
+
*/
|
|
1499
|
+
function bridgeBody() {
|
|
1500
|
+
if (window.__dshSelBridge) return
|
|
1501
|
+
window.__dshSelBridge = 1
|
|
1502
|
+
var WINDOW_CHARS = 1500
|
|
1503
|
+
var KEY_CHARS = 300
|
|
1504
|
+
var MAX_SELECTION = 4000
|
|
1505
|
+
/** 鼠标按着超过这么久还没等到 mouseup,就当松手落在帧外了(别永久卡住上报)。 */
|
|
1506
|
+
var STALE_PRESS_MS = 6000
|
|
1507
|
+
/** 轮询连续这么多拍都读不到选区,才认为选区真没了(约 1.5s;瞬时读不到不清)。 */
|
|
1508
|
+
var NULL_TICKS_TO_CLEAR = 6
|
|
1509
|
+
/** 上一次报出去的选区指纹(文字 + 位置),用来去重。 */
|
|
1510
|
+
var last = ''
|
|
1511
|
+
/** 鼠标是不是按着(拖拽划词进行中)。 */
|
|
1512
|
+
var pressed = false
|
|
1513
|
+
var pressedAt = 0
|
|
1514
|
+
/** 上一拍轮询看到的指纹:用于"稳定一拍再报"。 */
|
|
1515
|
+
var pollKey = ''
|
|
1516
|
+
/** 轮询连续读不到选区的拍数。 */
|
|
1517
|
+
var nullTicks = 0
|
|
1518
|
+
|
|
1519
|
+
function norm(text) {
|
|
1520
|
+
return String(text || '').replace(/\s+/g, ' ').trim()
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function send(kind, sel) {
|
|
1524
|
+
try {
|
|
1525
|
+
parent.postMessage({ __dshSel: 1, kind: kind, sel: sel || null }, '*')
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
/* 顶层被导航走了之类:报不出去就算了,不能因为报错把预览页搞坏 */
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** 选区所在的语义容器:与父页面 pickContainer 同一套规则(往上找到第一个够长的元素)。 */
|
|
1532
|
+
function containerOf(node) {
|
|
1533
|
+
var element = node && node.nodeType === 1 ? node : node && node.parentElement
|
|
1534
|
+
while (element && element !== document.body) {
|
|
1535
|
+
var length = (element.innerText || '').length
|
|
1536
|
+
if (length >= 120) return element
|
|
1537
|
+
element = element.parentElement
|
|
1538
|
+
}
|
|
1539
|
+
return element || document.body
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function labelOf(element) {
|
|
1543
|
+
try {
|
|
1544
|
+
if (element && element.closest && element.closest('pre,code')) return '代码块'
|
|
1545
|
+
if (element && element.closest && element.closest('table')) return '表格'
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
/* 孤立节点上 closest 会抛,忽略 */
|
|
1548
|
+
}
|
|
1549
|
+
return ''
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** 这些标签的文本不算正文(脚本源码 / 样式 / 模板 / 文档头)。 */
|
|
1553
|
+
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1 }
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* 走 DOM 收集容器的可见文本,并给出 `target`(选区起点)在这段文本里的偏移。
|
|
1557
|
+
* 比 `Range.toString()` 多一步:跳过 script/style 等——否则页面里的 JS 源码会混进上下文。
|
|
1558
|
+
* target 是元素节点(整段选中的情况)时,取"进入该元素那一刻"的长度作为偏移;
|
|
1559
|
+
* 是文本节点时还要加上 `targetOffset`(选区在文本节点内的字符偏移)。
|
|
1560
|
+
*/
|
|
1561
|
+
function collectText(root, target, targetOffset) {
|
|
1562
|
+
var text = ''
|
|
1563
|
+
var offset = -1
|
|
1564
|
+
function walk(node) {
|
|
1565
|
+
if (!node) return
|
|
1566
|
+
if (node.nodeType === 3) {
|
|
1567
|
+
if (node === target) offset = text.length + (targetOffset || 0)
|
|
1568
|
+
text += node.nodeValue || ''
|
|
1569
|
+
return
|
|
1570
|
+
}
|
|
1571
|
+
if (node.nodeType !== 1) return
|
|
1572
|
+
if (SKIP_TAGS[node.tagName]) return
|
|
1573
|
+
if (node === target) offset = text.length
|
|
1574
|
+
var kids = node.childNodes || []
|
|
1575
|
+
for (var i = 0; i < kids.length; i += 1) walk(kids[i])
|
|
1576
|
+
}
|
|
1577
|
+
walk(root)
|
|
1578
|
+
return { text: text, offset: offset }
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** 读一次选区:文字 + 上下文窗口 + 帧内坐标;没有有效选区返回 null。 */
|
|
1582
|
+
function read() {
|
|
1583
|
+
var selection = window.getSelection && window.getSelection()
|
|
1584
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null
|
|
1585
|
+
var text = String(selection.toString() || '')
|
|
1586
|
+
if (!text.trim() || text.length > MAX_SELECTION) return null
|
|
1587
|
+
var range
|
|
1588
|
+
try {
|
|
1589
|
+
range = selection.getRangeAt(0)
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
return null
|
|
1592
|
+
}
|
|
1593
|
+
var start = range.startContainer
|
|
1594
|
+
var startElement = start && start.nodeType === 1 ? start : start && start.parentElement
|
|
1595
|
+
try {
|
|
1596
|
+
if (startElement && startElement.closest && startElement.closest('input,textarea')) return null
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
/* 输入框判断失败不致命 */
|
|
1599
|
+
}
|
|
1600
|
+
var container = containerOf(startElement)
|
|
1601
|
+
var out = { text: text.trim(), context: '', keyContext: '', label: labelOf(container) }
|
|
1602
|
+
try {
|
|
1603
|
+
// 容器正文:**走 DOM 取文本**,跳过 script / style / noscript / template / head。
|
|
1604
|
+
// 不能用 Range.toString():它把 <script> 里的源码也算进正文——实测(真浏览器冒烟)
|
|
1605
|
+
// 交互式预览里的上下文会带上整段页面 JS,喂给模型纯属噪音。
|
|
1606
|
+
var walked = collectText(container, range.startContainer, range.startOffset)
|
|
1607
|
+
var rawFull = walked.text
|
|
1608
|
+
var offset = walked.offset
|
|
1609
|
+
if (offset < 0) {
|
|
1610
|
+
// 选区起点不在这个容器里(跨容器选区之类):退回 Range 的算法,至少别丢上下文
|
|
1611
|
+
var prefix = document.createRange()
|
|
1612
|
+
prefix.selectNodeContents(container)
|
|
1613
|
+
prefix.setEnd(range.startContainer, range.startOffset)
|
|
1614
|
+
offset = prefix.toString().length
|
|
1615
|
+
}
|
|
1616
|
+
var from = Math.max(0, offset - WINDOW_CHARS)
|
|
1617
|
+
var to = Math.min(rawFull.length, offset + text.length + WINDOW_CHARS)
|
|
1618
|
+
var windowText = rawFull.slice(from, to)
|
|
1619
|
+
var relative = offset - from
|
|
1620
|
+
var probe = windowText.slice(relative, relative + text.length)
|
|
1621
|
+
var marked
|
|
1622
|
+
if (norm(probe) === norm(text)) {
|
|
1623
|
+
marked = windowText.slice(0, relative) + '【' + probe + '】' + windowText.slice(relative + text.length)
|
|
1624
|
+
} else {
|
|
1625
|
+
var found = rawFull.indexOf(text)
|
|
1626
|
+
if (found >= 0) {
|
|
1627
|
+
var f2 = Math.max(0, found - WINDOW_CHARS)
|
|
1628
|
+
var t2 = Math.min(rawFull.length, found + text.length + WINDOW_CHARS)
|
|
1629
|
+
var w2 = rawFull.slice(f2, t2)
|
|
1630
|
+
var r2 = found - f2
|
|
1631
|
+
marked = w2.slice(0, r2) + '【' + w2.slice(r2, r2 + text.length) + '】' + w2.slice(r2 + text.length)
|
|
1632
|
+
} else {
|
|
1633
|
+
marked = text + '\n---\n' + windowText
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
out.context = marked
|
|
1637
|
+
.replace(/\u00a0/g, ' ')
|
|
1638
|
+
.replace(/[ \t]+/g, ' ')
|
|
1639
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
1640
|
+
.trim()
|
|
1641
|
+
out.keyContext = rawFull
|
|
1642
|
+
.slice(Math.max(0, offset - KEY_CHARS), offset)
|
|
1643
|
+
.replace(/\u00a0/g, ' ')
|
|
1644
|
+
.replace(/\s+/g, ' ')
|
|
1645
|
+
.trim()
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
/* 取不到上下文就给空串:父页面照样能开面板,只是少了背景 */
|
|
1648
|
+
}
|
|
1649
|
+
var rect = null
|
|
1650
|
+
try {
|
|
1651
|
+
var rects = range.getClientRects()
|
|
1652
|
+
rect = rects && rects.length > 0 ? rects[rects.length - 1] : null
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
rect = null
|
|
1655
|
+
}
|
|
1656
|
+
if (!rect) {
|
|
1657
|
+
try {
|
|
1658
|
+
rect = range.getBoundingClientRect()
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
rect = null
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (!rect || (!rect.width && !rect.height)) return null
|
|
1664
|
+
out.rect = {
|
|
1665
|
+
x: rect.left,
|
|
1666
|
+
y: rect.top,
|
|
1667
|
+
right: rect.right,
|
|
1668
|
+
bottom: rect.bottom,
|
|
1669
|
+
w: rect.width,
|
|
1670
|
+
h: rect.height,
|
|
1671
|
+
}
|
|
1672
|
+
return out
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** 选区指纹:文字 + 帧内位置(位置变了也要重报,父页面据此挪浮标)。 */
|
|
1676
|
+
function fingerprint(now) {
|
|
1677
|
+
if (!now) return ''
|
|
1678
|
+
return now.text + '\u0000' + now.rect.x + ',' + now.rect.y + ',' + now.rect.bottom + ',' + now.rect.right
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* 读一次并上报。
|
|
1683
|
+
*
|
|
1684
|
+
* `allowClear === false` 时**只报选区、不报"选区没了"**:滚动/改尺寸/轮询这些
|
|
1685
|
+
* "顺手重报一下位置"的路径不该有清空权 —— 页面重绘、动画、字体回流时
|
|
1686
|
+
* `getClientRects()` 会短暂拿不到矩形,一抖就 clear 会把浮标误收掉
|
|
1687
|
+
* (用户看到的就是"浮标自己消失了")。真正的清空信号来自 mouseup / keyup /
|
|
1688
|
+
* selectionchange 这些**用户动作**。
|
|
1689
|
+
*/
|
|
1690
|
+
function report(allowClear) {
|
|
1691
|
+
var now = read()
|
|
1692
|
+
if (!now) {
|
|
1693
|
+
if (last && allowClear !== false) {
|
|
1694
|
+
last = ''
|
|
1695
|
+
send('clear', null)
|
|
1696
|
+
}
|
|
1697
|
+
return
|
|
1698
|
+
}
|
|
1699
|
+
var key = fingerprint(now)
|
|
1700
|
+
if (key === last) return
|
|
1701
|
+
last = key
|
|
1702
|
+
send('selection', now)
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* 轮询兜底(250ms):只负责"没人通知我们"的情况。
|
|
1707
|
+
*
|
|
1708
|
+
* 三道闸门都是为了**时机**跟主会话一致(主会话只在 mouseup / keyup 后弹):
|
|
1709
|
+
* ① 鼠标按着(拖拽划词进行中)一律不报 —— 否则拖到一半浮标就冒出来,还跟着手指跑;
|
|
1710
|
+
* ② 选区还在变(这一拍与上一拍指纹不同)先记账,**稳定一拍**再报;
|
|
1711
|
+
* ③ 连续 NULL_TICKS_TO_CLEAR 拍都读不到选区才认为它真没了 —— 一次瞬时读不到
|
|
1712
|
+
* 只当页面在重绘,不清浮标(瞬时误清的表现就是"浮标自己消失")。
|
|
1713
|
+
* 松手落在帧外时不会有 mouseup:超过 STALE_PRESS_MS 就当已经松开,别把上报永久卡死。
|
|
1714
|
+
*/
|
|
1715
|
+
function tick() {
|
|
1716
|
+
if (pressed) {
|
|
1717
|
+
if (Date.now() - pressedAt < STALE_PRESS_MS) return
|
|
1718
|
+
pressed = false
|
|
1719
|
+
}
|
|
1720
|
+
var now = read()
|
|
1721
|
+
if (!now) {
|
|
1722
|
+
pollKey = ''
|
|
1723
|
+
nullTicks += 1
|
|
1724
|
+
if (nullTicks >= NULL_TICKS_TO_CLEAR && last) {
|
|
1725
|
+
last = ''
|
|
1726
|
+
send('clear', null)
|
|
1727
|
+
}
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
nullTicks = 0
|
|
1731
|
+
var key = fingerprint(now)
|
|
1732
|
+
if (key !== pollKey) {
|
|
1733
|
+
pollKey = key
|
|
1734
|
+
return
|
|
1735
|
+
}
|
|
1736
|
+
if (key === last) return
|
|
1737
|
+
last = key
|
|
1738
|
+
send('selection', now)
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
window.addEventListener('mousedown', function () {
|
|
1742
|
+
pressed = true
|
|
1743
|
+
pressedAt = Date.now()
|
|
1744
|
+
// 帧内按下 = 用户开始新动作 → 让父页面先把浮标收掉。
|
|
1745
|
+
// 主会话那边 document 的 mousedown 就是"点哪都先收起浮标";帧内的点击父页面收不到,
|
|
1746
|
+
// 不补这一条的话:在网页里点一下(取消选区/点别处)浮标会赖着不走。
|
|
1747
|
+
send('press', null)
|
|
1748
|
+
}, true)
|
|
1749
|
+
// 松手才是"划完了":与主会话一致(那边也是 mouseup 之后才弹浮标)
|
|
1750
|
+
window.addEventListener('mouseup', function () {
|
|
1751
|
+
pressed = false
|
|
1752
|
+
setTimeout(report, 0)
|
|
1753
|
+
}, true)
|
|
1754
|
+
window.addEventListener('keyup', function () { setTimeout(report, 0) }, true)
|
|
1755
|
+
// 焦点离开这一帧(点去别处)= 拖拽状态作废,免得 pressed 卡住
|
|
1756
|
+
window.addEventListener('blur', function () { pressed = false }, true)
|
|
1757
|
+
// 帧内滚动/改尺寸 → 位置变了,重报一次(父页面据此挪浮标)。这两条不许 clear
|
|
1758
|
+
window.addEventListener('scroll', function () {
|
|
1759
|
+
if (pressed) return
|
|
1760
|
+
setTimeout(function () {
|
|
1761
|
+
report(false)
|
|
1762
|
+
}, 60)
|
|
1763
|
+
}, true)
|
|
1764
|
+
window.addEventListener('resize', function () {
|
|
1765
|
+
if (!pressed) report(false)
|
|
1766
|
+
})
|
|
1767
|
+
window.addEventListener('message', function (event) {
|
|
1768
|
+
var data = event && event.data
|
|
1769
|
+
if (!data || data.__dshSel !== 1) return
|
|
1770
|
+
if (data.kind === 'ping') {
|
|
1771
|
+
send('hello', null)
|
|
1772
|
+
report()
|
|
1773
|
+
}
|
|
1774
|
+
if (data.kind === 'rescan') report()
|
|
1775
|
+
})
|
|
1776
|
+
try {
|
|
1777
|
+
document.addEventListener('selectionchange', function () {
|
|
1778
|
+
// 拖拽中不报;键盘扩选(Shift+方向键)没有 mousedown,照旧即时上报
|
|
1779
|
+
if (!pressed) report()
|
|
1780
|
+
})
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
/* 老环境没有 selectionchange:轮询兜着 */
|
|
1783
|
+
}
|
|
1784
|
+
setInterval(tick, 250)
|
|
1785
|
+
setTimeout(report, 60)
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** 注入标记:既用来认"这份 HTML 已经桥过",也是 iframe 元素上的记号。 */
|
|
1789
|
+
var BRIDGE_MARK = 'data-dsh-sel-bridge'
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* 把桥脚本插进一份 HTML(返回新串;没有可插的地方 / 已经插过 → null)。
|
|
1793
|
+
*
|
|
1794
|
+
* 基础预览(srcdoc)那份 HTML 已经过宿主 DOMPurify 清洗:无脚本、无外链,
|
|
1795
|
+
* 且带一条 `script-src 'none'` 的 CSP。我们**只放宽这一条**到 'unsafe-inline'
|
|
1796
|
+
* (好让桥跑起来),`default-src 'none'` / `connect-src 'none'` / `img-src data:`
|
|
1797
|
+
* 等其余限制原样保留 —— 预览页依然联不了网、加载不了外部资源。
|
|
1798
|
+
*/
|
|
1799
|
+
function bridgeIntoHtml(html) {
|
|
1800
|
+
var text = String(html || '')
|
|
1801
|
+
if (!text || text.indexOf(BRIDGE_MARK) >= 0) return null
|
|
1802
|
+
text = text.replace(/script-src\s+'none'/i, "script-src 'unsafe-inline'")
|
|
1803
|
+
var tag = '<scr' + 'ipt ' + BRIDGE_MARK + '>(' + String(bridgeBody) + ')();</scr' + 'ipt>'
|
|
1804
|
+
return insertIntoHtml(text, tag)
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* 插入位置按"尽量靠前、但别破坏文档"排序:
|
|
1809
|
+
* charset(中文标签不能变乱码)→ head → html → 第一个 script 之前 → doctype 之后 → 最前。
|
|
1810
|
+
*/
|
|
1811
|
+
function insertIntoHtml(html, tag) {
|
|
1812
|
+
var anchors = [/<meta[^>]+charset[^>]*>/i, /<head[^>]*>/i, /<html[^>]*>/i]
|
|
1813
|
+
for (var i = 0; i < anchors.length; i += 1) {
|
|
1814
|
+
var hit = anchors[i].exec(html)
|
|
1815
|
+
if (hit) {
|
|
1816
|
+
var cut = hit.index + hit[0].length
|
|
1817
|
+
return html.slice(0, cut) + tag + html.slice(cut)
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
var script = /<script/i.exec(html)
|
|
1821
|
+
if (script) return html.slice(0, script.index) + tag + html.slice(script.index)
|
|
1822
|
+
var doctype = /^\s*<!doctype[^>]*>/i.exec(html)
|
|
1823
|
+
if (doctype) {
|
|
1824
|
+
var end = doctype.index + doctype[0].length
|
|
1825
|
+
return html.slice(0, end) + tag + html.slice(end)
|
|
1826
|
+
}
|
|
1827
|
+
return tag + html
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1279
1830
|
// ────────────────────── 主逻辑 ──────────────────────
|
|
1280
1831
|
|
|
1281
1832
|
function apply(ctx) {
|
|
@@ -1289,6 +1840,11 @@ window.__ModuleLoader__.load({
|
|
|
1289
1840
|
raw: '',
|
|
1290
1841
|
/** 'idle' | 'loading' | 'streaming' | 'done' | 'error'。 */
|
|
1291
1842
|
phase: 'idle',
|
|
1843
|
+
/**
|
|
1844
|
+
* 当前选区是不是来自**侧边栏网页里的桥**(iframe 帧内上报)。
|
|
1845
|
+
* 为 true 时顶层选区塌掉不算"选区没了"——那种塌陷是我们点浮标造成的。
|
|
1846
|
+
*/
|
|
1847
|
+
bridgeActive: false,
|
|
1292
1848
|
/** 计时与统计。 */
|
|
1293
1849
|
startedAt: 0,
|
|
1294
1850
|
elapsed: 0,
|
|
@@ -1327,6 +1883,15 @@ window.__ModuleLoader__.load({
|
|
|
1327
1883
|
stage: '',
|
|
1328
1884
|
/** 追问轮次:[{ role: 'user' | 'assistant', text }],只活在面板/内存里(临时)。 */
|
|
1329
1885
|
turns: [],
|
|
1886
|
+
/**
|
|
1887
|
+
* 待发送的引用:[{ label: '小窗回答' | '主界面选中' …, text }]。
|
|
1888
|
+
* 只属于**下一条**提问(发送即清空);关掉面板不清 —— 回来接着写还看得见。
|
|
1889
|
+
*/
|
|
1890
|
+
quotes: [],
|
|
1891
|
+
/** 最近一次"可以引用"的选区:[{ text, label, rect, source }](浮标据此显示)。 */
|
|
1892
|
+
quoteSelection: null,
|
|
1893
|
+
/** 正在取引用的会话上下文(这期间再按 Enter 不能空发一条)。 */
|
|
1894
|
+
resolvingQuotes: false,
|
|
1330
1895
|
/** 最近一条状态文本(界面上不显示,只给自检用)。 */
|
|
1331
1896
|
lastStatus: '',
|
|
1332
1897
|
/** 是不是"跟着最新消息走"(发完消息自动置真;用户自己往上滚就交回给他)。 */
|
|
@@ -1379,6 +1944,25 @@ window.__ModuleLoader__.load({
|
|
|
1379
1944
|
button.appendChild(el('span', null, '解读'))
|
|
1380
1945
|
layer.appendChild(button)
|
|
1381
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
|
+
|
|
1382
1966
|
/**
|
|
1383
1967
|
* 悬浮状态胶囊:显示"最近一次划词现在处于什么状态",点一下回到那个小窗。
|
|
1384
1968
|
* 位置由 placePill() 动态贴到右下角费用胶囊(dsh-spend)上方;老版本没装那个插件时用兜底位置。
|
|
@@ -1436,6 +2020,10 @@ window.__ModuleLoader__.load({
|
|
|
1436
2020
|
var chatLog = el('div', 'dsh-sel-chatlog')
|
|
1437
2021
|
body.appendChild(chatLog)
|
|
1438
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)
|
|
1439
2027
|
var askBox = el('textarea', 'dsh-sel-askbox')
|
|
1440
2028
|
askBox.rows = 1
|
|
1441
2029
|
askBox.placeholder = '就这段文字继续追问…(Enter 发送,Shift+Enter 换行)'
|
|
@@ -1465,6 +2053,14 @@ window.__ModuleLoader__.load({
|
|
|
1465
2053
|
askSend.type = 'button'
|
|
1466
2054
|
askSend.setAttribute('aria-label', '发送')
|
|
1467
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())
|
|
1468
2064
|
// 模型 + 推理等级(追问档):胶囊在发送键左边,菜单向上弹
|
|
1469
2065
|
var modelPill = el('button', 'dsh-sel-picker')
|
|
1470
2066
|
modelPill.type = 'button'
|
|
@@ -1478,14 +2074,71 @@ window.__ModuleLoader__.load({
|
|
|
1478
2074
|
var modelMenu = el('div', 'dsh-sel-pickermenu')
|
|
1479
2075
|
modelMenu.setAttribute('role', 'menu')
|
|
1480
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
|
+
|
|
1481
2129
|
var askTools = el('div', 'dsh-sel-asktools')
|
|
2130
|
+
var askSpacer = el('span', 'dsh-sel-askspace')
|
|
1482
2131
|
askTools.appendChild(webMode)
|
|
1483
|
-
askTools.appendChild(
|
|
2132
|
+
askTools.appendChild(askSpacer)
|
|
1484
2133
|
askTools.appendChild(modelPill)
|
|
2134
|
+
askTools.appendChild(micButton)
|
|
2135
|
+
askTools.appendChild(captureRow)
|
|
1485
2136
|
askTools.appendChild(askSend)
|
|
1486
2137
|
askRow.appendChild(askBox)
|
|
1487
2138
|
askRow.appendChild(askTools)
|
|
1488
2139
|
askRow.appendChild(modelMenu)
|
|
2140
|
+
// 引用区先画一次(空态:data-show="0" 收起),别等第一次 addQuote 才建立初始状态
|
|
2141
|
+
renderQuotes()
|
|
1489
2142
|
// 「重新生成」不再常驻页脚:只在内容区没有可用结果时出现在正文里
|
|
1490
2143
|
var retryButton = el('button', 'dsh-sel-retry', '重新生成')
|
|
1491
2144
|
retryButton.type = 'button'
|
|
@@ -1504,6 +2157,8 @@ window.__ModuleLoader__.load({
|
|
|
1504
2157
|
* React 组件只负责把 layer 容器挂进浮层;浮标/面板由上面的命令式代码管理,
|
|
1505
2158
|
* React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
|
|
1506
2159
|
*/
|
|
2160
|
+
/** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
|
|
2161
|
+
var askFocusPending = false
|
|
1507
2162
|
var offSlot = null
|
|
1508
2163
|
if (ctx.slots && typeof ctx.slots.inject === 'function') {
|
|
1509
2164
|
try {
|
|
@@ -1581,13 +2236,154 @@ window.__ModuleLoader__.load({
|
|
|
1581
2236
|
return false
|
|
1582
2237
|
}
|
|
1583
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
|
+
|
|
1584
2369
|
function checkSelection() {
|
|
1585
|
-
if (panelOpen)
|
|
2370
|
+
if (panelOpen) {
|
|
2371
|
+
checkQuoteSelection()
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
hideQuoteButton()
|
|
2375
|
+
state.quoteSelection = null
|
|
1586
2376
|
var selection = window.getSelection()
|
|
1587
2377
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
2378
|
+
// 侧边栏网页里的选区**不在本文档**:点浮标会让本文档的选区塌掉
|
|
2379
|
+
// (于是每次点浮标都会先跑到这里),所以只要帧内那条选区还活着就别收浮标。
|
|
2380
|
+
// 帧内清空选区时桥会发 clear,那里才是真正的"该收"信号。
|
|
2381
|
+
if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
|
|
1588
2382
|
hideButton()
|
|
1589
2383
|
return
|
|
1590
2384
|
}
|
|
2385
|
+
// 本文档里出现了真选区:帧内那条让位(两处同时有选区时以本文档为准)
|
|
2386
|
+
state.bridgeActive = false
|
|
1591
2387
|
// 划到自己界面上(胶囊/面板/历史列表)时什么都不做
|
|
1592
2388
|
try {
|
|
1593
2389
|
var ownRange = selection.getRangeAt(0)
|
|
@@ -1614,7 +2410,7 @@ window.__ModuleLoader__.load({
|
|
|
1614
2410
|
hideButton()
|
|
1615
2411
|
return
|
|
1616
2412
|
}
|
|
1617
|
-
state.selection = { text: text.trim(), range: range.cloneRange(), rect: rect }
|
|
2413
|
+
state.selection = { text: text.trim(), range: range.cloneRange(), rect: rect, source: 'document' }
|
|
1618
2414
|
showButton(rect)
|
|
1619
2415
|
}
|
|
1620
2416
|
|
|
@@ -1627,17 +2423,8 @@ window.__ModuleLoader__.load({
|
|
|
1627
2423
|
function showButton(rect) {
|
|
1628
2424
|
// 浮标也在同一套配色下:它的底色是"页面背景"(浮层自身透明),按它判深浅
|
|
1629
2425
|
if (!panelOpen) layer.setAttribute('data-theme', isDarkSurface(layer) ? 'dark' : 'light')
|
|
1630
|
-
var wasHidden = button.style.display !== 'inline-flex'
|
|
1631
|
-
button.style.display = 'inline-flex'
|
|
1632
|
-
var width = button.offsetWidth || 62
|
|
1633
|
-
var height = button.offsetHeight || 28
|
|
1634
|
-
var left = clamp(rect.right - width, 8, Math.max(8, window.innerWidth - width - 8))
|
|
1635
|
-
var top = rect.top - height - 7
|
|
1636
|
-
if (top < 8) top = Math.min(rect.bottom + 7, window.innerHeight - height - 8)
|
|
1637
|
-
button.style.left = Math.round(left) + 'px'
|
|
1638
|
-
button.style.top = Math.round(top) + 'px'
|
|
1639
2426
|
// 只在"浮现"那一次播动效;已经可见时(划选范围被拖动、键盘调整)只平移,避免一直闪
|
|
1640
|
-
if (
|
|
2427
|
+
if (placeFloat(button, rect)) {
|
|
1641
2428
|
setButtonPop(false)
|
|
1642
2429
|
void button.offsetWidth // 强制重排,让动画能重播
|
|
1643
2430
|
setButtonPop(true)
|
|
@@ -1649,68 +2436,692 @@ window.__ModuleLoader__.load({
|
|
|
1649
2436
|
setButtonPop(false)
|
|
1650
2437
|
}
|
|
1651
2438
|
|
|
1652
|
-
// —— 面板 ——
|
|
1653
|
-
|
|
1654
|
-
/** 当前会话 id(客户端 sessions 快照;取不到就留空,host 会跳过会话背景)。 */
|
|
1655
|
-
function currentSessionId() {
|
|
1656
|
-
try {
|
|
1657
|
-
var sessions = ctx.get('sessions')
|
|
1658
|
-
var store = sessions && sessions.list
|
|
1659
|
-
var snapshot = store && typeof store.getSnapshot === 'function' ? store.getSnapshot() : null
|
|
1660
|
-
var id = snapshot && snapshot.current
|
|
1661
|
-
return id ? String(id) : ''
|
|
1662
|
-
} catch (error) {
|
|
1663
|
-
return ''
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
2439
|
/**
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
2440
|
+
* 浮标定位(「解读」与「引用」两个浮标共用一套规则):
|
|
2441
|
+
* 贴选区右下角;上面放不下(贴到视口顶)就翻到选区下方;左右夹回视口内。
|
|
2442
|
+
* 返回"这次是从隐藏变可见" —— 调用方据此决定要不要播浮现动效。
|
|
1670
2443
|
*/
|
|
1671
|
-
function
|
|
1672
|
-
|
|
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
|
|
1673
2455
|
}
|
|
1674
2456
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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)
|
|
1681
2467
|
}
|
|
1682
|
-
return false
|
|
1683
2468
|
}
|
|
1684
2469
|
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
* 行首赋值/调用、花括号分号收尾、缩进块、关键字、运算符、注释行……
|
|
1689
|
-
* 阈值刻意偏高——宁可把一段英文当散文,也不要把散文当代码去"逐句注释"。
|
|
1690
|
-
*/
|
|
1691
|
-
function looksLikeCode(text) {
|
|
1692
|
-
var t = String(text || '')
|
|
1693
|
-
if (!t.trim()) return false
|
|
1694
|
-
if (t.indexOf('\n') < 0 && t.trim().length < 24) return false // 单个短词几乎不可能是代码块
|
|
1695
|
-
var strong = 0
|
|
1696
|
-
if (/^\s*(?:[\w$.]+\s*[=(]|\}|\{|<[a-zA-Z/!])/m.test(t)) strong += 1
|
|
1697
|
-
if (/\{\s*$|\}\s*$|;\s*$/m.test(t)) strong += 1
|
|
1698
|
-
if (/^\s{2,}\S/m.test(t)) strong += 1
|
|
1699
|
-
if (/\b(function|def|class|import|export|const|let|var|return|async|await|public|private|static|interface|SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|npm|pnpm|yarn|git|docker|curl)\b/.test(t)) strong += 1
|
|
1700
|
-
if (/=>|->|::|!=|===?|\+=|&&|\|\||<\/[a-zA-Z]/.test(t)) strong += 1
|
|
1701
|
-
if (/^\s*(#|\/\/|\/\*|\*|--|<!--)/m.test(t)) strong += 1
|
|
1702
|
-
var lines = t.split('\n').filter(function (line) { return line.trim() })
|
|
1703
|
-
if (strong >= 2) return true
|
|
1704
|
-
return lines.length >= 3 && strong >= 1 && /[{};=()]/.test(t)
|
|
2470
|
+
function hideQuoteButton() {
|
|
2471
|
+
quoteButton.style.display = 'none'
|
|
2472
|
+
setQuotePop(false)
|
|
1705
2473
|
}
|
|
1706
2474
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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) : '引用'
|
|
1711
2488
|
}
|
|
1712
2489
|
|
|
1713
|
-
/**
|
|
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
|
+
|
|
2761
|
+
// —— 侧边栏网页(iframe)划词桥的父侧接线 ——
|
|
2762
|
+
|
|
2763
|
+
/**
|
|
2764
|
+
* 我们桥过的帧:frame → 自己建的 blob url('' = srcdoc 帧,没有 blob 要回收)。
|
|
2765
|
+
* 用 Map 而不是数组:既要按帧查自己的 url,也要遍历找回消息来源对应的帧。
|
|
2766
|
+
*/
|
|
2767
|
+
var bridged = new Map()
|
|
2768
|
+
/** 总开关:host 的 bridgeSidebarPreview 说关就关(见下面 ping 分支)。 */
|
|
2769
|
+
var bridgeOn = true
|
|
2770
|
+
/** 扫描节流用的定时器 id。 */
|
|
2771
|
+
var bridgeScanId = 0
|
|
2772
|
+
var bridgeObserver = null
|
|
2773
|
+
|
|
2774
|
+
function ownedUrl(frame) {
|
|
2775
|
+
var url = bridged.get(frame)
|
|
2776
|
+
return url === undefined ? '' : url
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/** 消息来源 → 帧(只认我们桥过的帧,别的窗口发来的消息一概不理)。 */
|
|
2780
|
+
function frameBySource(source) {
|
|
2781
|
+
var found = null
|
|
2782
|
+
bridged.forEach(function (url, frame) {
|
|
2783
|
+
if (found) return
|
|
2784
|
+
try {
|
|
2785
|
+
if (frame.contentWindow && frame.contentWindow === source) found = frame
|
|
2786
|
+
} catch (error) {
|
|
2787
|
+
/* 跨源取 contentWindow 可能抛,忽略 */
|
|
2788
|
+
}
|
|
2789
|
+
})
|
|
2790
|
+
return found
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/** 沙箱补一个 allow-scripts(**绝不补 allow-same-origin**):桥要跑起来。 */
|
|
2794
|
+
function ensureScriptsAllowed(frame) {
|
|
2795
|
+
var sandbox = frame.getAttribute('sandbox')
|
|
2796
|
+
if (sandbox === null) return // 没有 sandbox 属性 = 同源帧,不归这条路管
|
|
2797
|
+
if (!/(^|\s)allow-scripts(\s|$)/.test(sandbox)) {
|
|
2798
|
+
frame.setAttribute('sandbox', (sandbox + ' allow-scripts').trim())
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/** 帧内坐标 → 视口坐标;选区滚出帧的可视区就返回 null(免得浮标飘到主会话上)。 */
|
|
2803
|
+
function mapFrameRect(frame, reported) {
|
|
2804
|
+
if (!reported) return null
|
|
2805
|
+
var box
|
|
2806
|
+
try {
|
|
2807
|
+
box = frame.getBoundingClientRect()
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
return null
|
|
2810
|
+
}
|
|
2811
|
+
if (!box || (!box.width && !box.height)) return null
|
|
2812
|
+
var left = box.left + Number(reported.x || 0)
|
|
2813
|
+
var top = box.top + Number(reported.y || 0)
|
|
2814
|
+
var right = box.left + Number(reported.right || 0)
|
|
2815
|
+
var bottom = box.top + Number(reported.bottom || 0)
|
|
2816
|
+
if (!(right > left)) right = left + Number(reported.w || 0)
|
|
2817
|
+
if (!(bottom > top)) bottom = top + Number(reported.h || 0)
|
|
2818
|
+
if (bottom < box.top + 2 || top > box.bottom - 2) return null
|
|
2819
|
+
top = clamp(top, box.top, box.bottom)
|
|
2820
|
+
bottom = clamp(bottom, box.top, box.bottom)
|
|
2821
|
+
left = clamp(left, box.left, box.right)
|
|
2822
|
+
right = clamp(right, box.left, box.right)
|
|
2823
|
+
return {
|
|
2824
|
+
left: left,
|
|
2825
|
+
top: top,
|
|
2826
|
+
right: right,
|
|
2827
|
+
bottom: bottom,
|
|
2828
|
+
width: Math.max(0, right - left),
|
|
2829
|
+
height: Math.max(0, bottom - top),
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/** 面板里的位置标签:帧内认出来的(代码块/表格)优先,否则按帧的来源给。 */
|
|
2834
|
+
function labelForFrame(frame, frameLabel) {
|
|
2835
|
+
if (frameLabel === '代码块' || frameLabel === '表格') return frameLabel
|
|
2836
|
+
if (frame.getAttribute('data-html-preview') !== null) return '侧边栏网页'
|
|
2837
|
+
// 其他插件的网页(例如「图解」):用帧自己的 title 当位置标签,比笼统的"网页内容"有用
|
|
2838
|
+
var title = String(frame.getAttribute('title') || '')
|
|
2839
|
+
.replace(/\s+/g, ' ')
|
|
2840
|
+
.trim()
|
|
2841
|
+
return title ? title.slice(0, 24) : '网页内容'
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* 这一帧能不能桥。
|
|
2846
|
+
*
|
|
2847
|
+
* 只认"文档确实是我们能改写的那两种":`srcdoc` 或 `blob:` —— 远端站点 iframe 与
|
|
2848
|
+
* 路由 URL 的预览器(如 better-sidebar 的 HTML 预览)一概不动,它们的资源改写
|
|
2849
|
+
* 与生命周期归它们自己。
|
|
2850
|
+
*
|
|
2851
|
+
* 沙箱上分两档,因为"补 allow-scripts"是有代价的:
|
|
2852
|
+
* · 宿主自己的 HTML 预览(`data-html-preview`):内容已由宿主 DOMPurify 清洗成
|
|
2853
|
+
* "无脚本、无外链",补 allow-scripts 之后真正会跑的只有桥自己,可以补;
|
|
2854
|
+
* · 其他插件的网页(如「图解」的 `blob:` 帧):**只桥本来就允许脚本的**——
|
|
2855
|
+
* 不给别人的沙箱加权限,免得把一份刻意不跑脚本的文档变成会跑脚本。
|
|
2856
|
+
*/
|
|
2857
|
+
function bridgeableFrame(frame) {
|
|
2858
|
+
if (!frame || frame.nodeType !== 1 || !frame.isConnected) return false
|
|
2859
|
+
if (panel.contains(frame)) return false // 我们自己的小窗(含网页答案预览)不桥
|
|
2860
|
+
var hasSrcdoc = frame.getAttribute('srcdoc') !== null
|
|
2861
|
+
var src = frame.getAttribute('src') || ''
|
|
2862
|
+
if (!hasSrcdoc && !/^blob:/i.test(src)) return false
|
|
2863
|
+
var sandbox = frame.getAttribute('sandbox')
|
|
2864
|
+
if (sandbox === null) return false
|
|
2865
|
+
if (frame.getAttribute('data-html-preview') !== null) return true
|
|
2866
|
+
return /(^|\s)allow-scripts(\s|$)/.test(sandbox)
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function bridgeFrame(frame) {
|
|
2870
|
+
if (!bridgeOn || !bridgeableFrame(frame)) return
|
|
2871
|
+
var srcdoc = frame.getAttribute('srcdoc')
|
|
2872
|
+
if (srcdoc !== null && srcdoc !== undefined) {
|
|
2873
|
+
if (srcdoc.indexOf(BRIDGE_MARK) >= 0) return // 已经桥过(我们自己写回去的那份)
|
|
2874
|
+
var next = bridgeIntoHtml(srcdoc)
|
|
2875
|
+
if (!next) return
|
|
2876
|
+
// 顺序有讲究(Chrome 153 实测):**先补沙箱、再改 srcdoc,且必须在同一个任务里**。
|
|
2877
|
+
// 先改 srcdoc 再补沙箱、或者分两个任务改,帧都不会带着 allow-scripts 重新加载
|
|
2878
|
+
// (脚本静默不跑,表现为"注入了但没反应")。
|
|
2879
|
+
ensureScriptsAllowed(frame)
|
|
2880
|
+
frame.setAttribute('data-dsh-sel-bridged', '1')
|
|
2881
|
+
frame.setAttribute('srcdoc', next)
|
|
2882
|
+
bridged.set(frame, '')
|
|
2883
|
+
return
|
|
2884
|
+
}
|
|
2885
|
+
var src = frame.getAttribute('src') || ''
|
|
2886
|
+
// 只动 blob:(宿主与「图解」这类插件都是用它装生成页的)。
|
|
2887
|
+
// 路由 URL / 远端地址一概不碰:那些的资源改写与生命周期归宿主,插手只会弄坏预览。
|
|
2888
|
+
if (!/^blob:/i.test(src)) return
|
|
2889
|
+
if (src === ownedUrl(frame)) return // 已经是我们换上去的那份
|
|
2890
|
+
fetch(src)
|
|
2891
|
+
.then(function (response) {
|
|
2892
|
+
// 原 blob 的 MIME **要跟着搬**:blob: 文档没有"父文档编码"可继承,
|
|
2893
|
+
// 编码只来自这个 content-type(或页面里的 meta)。「图解」建 blob 时写的是
|
|
2894
|
+
// `text/html;charset=utf-8`,我们若退回裸 `text/html`,页面又没写 meta charset 的话,
|
|
2895
|
+
// 整页中文会变成乱码(实测过:`鍥捐В椤?`)。
|
|
2896
|
+
var type = 'text/html'
|
|
2897
|
+
try {
|
|
2898
|
+
var header = response.headers && response.headers.get && response.headers.get('content-type')
|
|
2899
|
+
if (header) type = header
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
/* 取不到就退回 text/html:与原行为一致 */
|
|
2902
|
+
}
|
|
2903
|
+
return response.text().then(function (text) {
|
|
2904
|
+
return { text: text, type: type }
|
|
2905
|
+
})
|
|
2906
|
+
})
|
|
2907
|
+
.then(function (loaded) {
|
|
2908
|
+
if (!bridgeOn || !frame.isConnected) return
|
|
2909
|
+
var bridgedHtml = bridgeIntoHtml(loaded.text)
|
|
2910
|
+
if (!bridgedHtml) return
|
|
2911
|
+
var url = URL.createObjectURL(new Blob([bridgedHtml], { type: loaded.type }))
|
|
2912
|
+
var previous = ownedUrl(frame)
|
|
2913
|
+
bridged.set(frame, url)
|
|
2914
|
+
ensureScriptsAllowed(frame)
|
|
2915
|
+
frame.setAttribute('data-dsh-sel-bridged', '1')
|
|
2916
|
+
frame.setAttribute('src', url)
|
|
2917
|
+
if (previous) {
|
|
2918
|
+
try {
|
|
2919
|
+
URL.revokeObjectURL(previous)
|
|
2920
|
+
} catch (error) {
|
|
2921
|
+
/* 回收失败不致命 */
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
})
|
|
2925
|
+
.catch(function () {
|
|
2926
|
+
/* 读不到就放着:原预览照常显示,只是这一帧没有划词桥 */
|
|
2927
|
+
})
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
/** 帧没了(关标签页/切源码视图)就顺手回收我们自己建的 blob,别漏。 */
|
|
2931
|
+
function pruneBridged() {
|
|
2932
|
+
var dead = []
|
|
2933
|
+
bridged.forEach(function (url, frame) {
|
|
2934
|
+
if (!frame.isConnected) dead.push(frame)
|
|
2935
|
+
})
|
|
2936
|
+
for (var i = 0; i < dead.length; i += 1) {
|
|
2937
|
+
var url = bridged.get(dead[i])
|
|
2938
|
+
if (url) {
|
|
2939
|
+
try {
|
|
2940
|
+
URL.revokeObjectURL(url)
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
/* 回收失败不致命 */
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// 帧没了就不会再有 clear 消息了:把它占着的那条选区一起清掉,
|
|
2946
|
+
// 否则 `bridgeActive` 会一直挂着(表现为顶层选区塌掉也不收浮标)。
|
|
2947
|
+
if (state.selection && state.selection.frame === dead[i]) {
|
|
2948
|
+
state.selection = null
|
|
2949
|
+
state.bridgeActive = false
|
|
2950
|
+
hideButton()
|
|
2951
|
+
}
|
|
2952
|
+
bridged.delete(dead[i])
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
function scanPreviewFrames() {
|
|
2957
|
+
if (!bridgeOn) return
|
|
2958
|
+
// 无浏览器测试的桩 DOM 没有 querySelectorAll:桥整体静默跳过
|
|
2959
|
+
if (typeof document.querySelectorAll !== 'function') return
|
|
2960
|
+
pruneBridged()
|
|
2961
|
+
// 扫全部 iframe 再按 bridgeableFrame 过滤:宿主的 HTML 预览、以及别的插件
|
|
2962
|
+
// 在侧边栏渲染的生成网页(「图解」那种 blob: 帧)都在这条路上,
|
|
2963
|
+
// 不能只看宿主的 data-html-preview 标记。
|
|
2964
|
+
var frames = document.querySelectorAll('iframe')
|
|
2965
|
+
for (var i = 0; i < frames.length; i += 1) bridgeFrame(frames[i])
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
function scheduleBridgeScan() {
|
|
2969
|
+
if (bridgeScanId) return
|
|
2970
|
+
bridgeScanId = later(function () {
|
|
2971
|
+
bridgeScanId = 0
|
|
2972
|
+
scanPreviewFrames()
|
|
2973
|
+
}, 120)
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* 帧内桥报过来的选区:换算坐标 → 复用同一套浮标与面板。
|
|
2978
|
+
*
|
|
2979
|
+
* 只认"我们桥过的帧"发来的消息(`frameBySource`)。
|
|
2980
|
+
* 小窗开着时它走**引用**那条路(划侧边栏网页里的文字,同样能挂进输入框);
|
|
2981
|
+
* 小窗关着时才是「✦ 解读」(要划新词开新窗,先按 Esc 收起当前这扇)。
|
|
2982
|
+
*/
|
|
2983
|
+
var offBridgeMessage = listen(window, 'message', function (event) {
|
|
2984
|
+
var data = event && event.data
|
|
2985
|
+
if (!data || data.__dshSel !== 1) return
|
|
2986
|
+
var frame = frameBySource(event.source)
|
|
2987
|
+
if (!frame) return
|
|
2988
|
+
if (data.kind === 'press') {
|
|
2989
|
+
// 帧内按下:与主会话一致——先收浮标。父页面收不到帧内的 mousedown,
|
|
2990
|
+
// 少了这一条,在网页里点一下(取消选区、点别处、点另一个网页)浮标就赖着不走。
|
|
2991
|
+
if (state.selection && state.selection.source === 'iframe' && state.selection.frame !== frame) {
|
|
2992
|
+
// 点的是**另一个**帧:那条选区已经不是用户此刻在看的了,连状态一起清
|
|
2993
|
+
state.selection = null
|
|
2994
|
+
state.bridgeActive = false
|
|
2995
|
+
}
|
|
2996
|
+
hideButton()
|
|
2997
|
+
hideQuoteButton()
|
|
2998
|
+
return
|
|
2999
|
+
}
|
|
3000
|
+
if (data.kind === 'clear') {
|
|
3001
|
+
if (state.selection && state.selection.frame === frame) {
|
|
3002
|
+
state.selection = null
|
|
3003
|
+
state.bridgeActive = false
|
|
3004
|
+
hideButton()
|
|
3005
|
+
hideQuoteButton()
|
|
3006
|
+
}
|
|
3007
|
+
return
|
|
3008
|
+
}
|
|
3009
|
+
if (data.kind !== 'selection' || !data.sel) return
|
|
3010
|
+
if (!frame.isConnected) return
|
|
3011
|
+
var text = String(data.sel.text || '')
|
|
3012
|
+
if (!text.trim() || text.length > MAX_SELECTION) return
|
|
3013
|
+
var rect = mapFrameRect(frame, data.sel.rect)
|
|
3014
|
+
if (!rect) {
|
|
3015
|
+
// 选区滚出帧的可视区:把浮标收掉(帧内再滚回来会重新报)
|
|
3016
|
+
if (state.selection && state.selection.frame === frame) {
|
|
3017
|
+
hideButton()
|
|
3018
|
+
hideQuoteButton()
|
|
3019
|
+
}
|
|
3020
|
+
return
|
|
3021
|
+
}
|
|
3022
|
+
state.selection = {
|
|
3023
|
+
text: text.trim(),
|
|
3024
|
+
/** 'document'(顶层选区,缺省)| 'iframe'(侧边栏网页,上下文由帧内桥给)。 */
|
|
3025
|
+
source: 'iframe',
|
|
3026
|
+
frame: frame,
|
|
3027
|
+
rect: rect,
|
|
3028
|
+
frameRect: data.sel.rect,
|
|
3029
|
+
context: String(data.sel.context || ''),
|
|
3030
|
+
keyContext: String(data.sel.keyContext || ''),
|
|
3031
|
+
label: labelForFrame(frame, data.sel.label),
|
|
3032
|
+
}
|
|
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
|
+
}
|
|
3047
|
+
showButton(rect)
|
|
3048
|
+
})
|
|
3049
|
+
|
|
3050
|
+
// 侧边栏里的预览帧是宿主 React 渲染的:新开标签、切预览/源码、文件变了都会换帧,
|
|
3051
|
+
// 所以既要初始扫一遍,也要盯着 DOM(只盯 srcdoc/src 两个属性 + 子节点增删)。
|
|
3052
|
+
if (typeof MutationObserver === 'function') {
|
|
3053
|
+
bridgeObserver = new MutationObserver(scheduleBridgeScan)
|
|
3054
|
+
bridgeObserver.observe(document.body || document.documentElement, {
|
|
3055
|
+
childList: true,
|
|
3056
|
+
subtree: true,
|
|
3057
|
+
attributes: true,
|
|
3058
|
+
attributeFilter: ['srcdoc', 'src'],
|
|
3059
|
+
})
|
|
3060
|
+
}
|
|
3061
|
+
scheduleBridgeScan()
|
|
3062
|
+
|
|
3063
|
+
// —— 面板 ——
|
|
3064
|
+
|
|
3065
|
+
/** 当前会话 id(客户端 sessions 快照;取不到就留空,host 会跳过会话背景)。 */
|
|
3066
|
+
function currentSessionId() {
|
|
3067
|
+
try {
|
|
3068
|
+
var sessions = ctx.get('sessions')
|
|
3069
|
+
var store = sessions && sessions.list
|
|
3070
|
+
var snapshot = store && typeof store.getSnapshot === 'function' ? store.getSnapshot() : null
|
|
3071
|
+
var id = snapshot && snapshot.current
|
|
3072
|
+
return id ? String(id) : ''
|
|
3073
|
+
} catch (error) {
|
|
3074
|
+
return ''
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* 缓存 key:选中文字 + 局部上下文片段(空白归一,避免多选/少选一个空格就换 key)。
|
|
3080
|
+
* 注意 key 里**不含**会话背景——本地缓存的作用就是"同一个词再点一次立刻回放"。
|
|
3081
|
+
*/
|
|
3082
|
+
function cacheKeyOf(text, context) {
|
|
3083
|
+
return normalizeSpace(text) + '\u0000' + normalizeSpace(context)
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/** 缓存里有没有"同一个词、但上下文不同"的旧结果(用来解释这次为什么没命中)。 */
|
|
3087
|
+
function hasSameTextElsewhere(text) {
|
|
3088
|
+
var prefix = normalizeSpace(text) + '\u0000'
|
|
3089
|
+
var keys = cache.keys()
|
|
3090
|
+
for (var step = keys.next(); !step.done; step = keys.next()) {
|
|
3091
|
+
if (step.value.indexOf(prefix) === 0) return true
|
|
3092
|
+
}
|
|
3093
|
+
return false
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
/**
|
|
3097
|
+
* 选中文字是不是**代码**。
|
|
3098
|
+
* 先看容器(<pre>/<code> 直接判代码),容器看不出来再按文本特征打分:
|
|
3099
|
+
* 行首赋值/调用、花括号分号收尾、缩进块、关键字、运算符、注释行……
|
|
3100
|
+
* 阈值刻意偏高——宁可把一段英文当散文,也不要把散文当代码去"逐句注释"。
|
|
3101
|
+
*/
|
|
3102
|
+
function looksLikeCode(text) {
|
|
3103
|
+
var t = String(text || '')
|
|
3104
|
+
if (!t.trim()) return false
|
|
3105
|
+
if (t.indexOf('\n') < 0 && t.trim().length < 24) return false // 单个短词几乎不可能是代码块
|
|
3106
|
+
var strong = 0
|
|
3107
|
+
if (/^\s*(?:[\w$.]+\s*[=(]|\}|\{|<[a-zA-Z/!])/m.test(t)) strong += 1
|
|
3108
|
+
if (/\{\s*$|\}\s*$|;\s*$/m.test(t)) strong += 1
|
|
3109
|
+
if (/^\s{2,}\S/m.test(t)) strong += 1
|
|
3110
|
+
if (/\b(function|def|class|import|export|const|let|var|return|async|await|public|private|static|interface|SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|npm|pnpm|yarn|git|docker|curl)\b/.test(t)) strong += 1
|
|
3111
|
+
if (/=>|->|::|!=|===?|\+=|&&|\|\||<\/[a-zA-Z]/.test(t)) strong += 1
|
|
3112
|
+
if (/^\s*(#|\/\/|\/\*|\*|--|<!--)/m.test(t)) strong += 1
|
|
3113
|
+
var lines = t.split('\n').filter(function (line) { return line.trim() })
|
|
3114
|
+
if (strong >= 2) return true
|
|
3115
|
+
return lines.length >= 3 && strong >= 1 && /[{};=()]/.test(t)
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
/** 'code' | 'text':代码走「注释」,文本走「翻译 / 解读」。 */
|
|
3119
|
+
function selectionKind(text, label) {
|
|
3120
|
+
if (label === '代码块') return 'code'
|
|
3121
|
+
return looksLikeCode(text) ? 'code' : 'text'
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
/**
|
|
1714
3125
|
* 选中文字里有没有"需要翻译的英文"(拉丁字母组成的词/句)。
|
|
1715
3126
|
* 决定了第一节的标题:有英文 → 「翻译」,纯中文 → 「解读」(纯汉字不翻译)。
|
|
1716
3127
|
* 规则要和 host 侧提示词一致:至少两个连续拉丁字母才算。
|
|
@@ -1745,6 +3156,11 @@ window.__ModuleLoader__.load({
|
|
|
1745
3156
|
}
|
|
1746
3157
|
|
|
1747
3158
|
function openForSelection(selection) {
|
|
3159
|
+
// 侧边栏网页里的选区:父页面读不到那个文档,上下文只能由帧内桥就地采好带过来
|
|
3160
|
+
if (selection.source === 'iframe') {
|
|
3161
|
+
openPanelWith(selection.text, selection.context || '', selection.label, selection.rect, selection.keyContext)
|
|
3162
|
+
return
|
|
3163
|
+
}
|
|
1748
3164
|
var contextInfo = collectContext(selection.range ? rangeSelection(selection.range) : window.getSelection())
|
|
1749
3165
|
openPanelWith(selection.text, contextInfo.context, contextInfo.label, selection.rect, contextInfo.keyContext)
|
|
1750
3166
|
}
|
|
@@ -1765,6 +3181,8 @@ window.__ModuleLoader__.load({
|
|
|
1765
3181
|
var cached = cache.get(cacheKey)
|
|
1766
3182
|
state.cacheKey = cacheKey
|
|
1767
3183
|
state.turns = []
|
|
3184
|
+
// 换了一段选中文字 = 换了一次对话:上一段攒的引用不该跟过来
|
|
3185
|
+
clearQuotes()
|
|
1768
3186
|
state.parts = (cached && cached.parts) || { translation: '', detail: '' }
|
|
1769
3187
|
state.stage = ''
|
|
1770
3188
|
state.raw = ''
|
|
@@ -2036,9 +3454,17 @@ window.__ModuleLoader__.load({
|
|
|
2036
3454
|
|
|
2037
3455
|
function showPanel(anchor) {
|
|
2038
3456
|
panelOpen = true
|
|
3457
|
+
// 键盘用户开窗即可打字:这一轮的输入框一旦可见就聚焦一次(见 focusComposerOnce)
|
|
3458
|
+
askFocusPending = true
|
|
3459
|
+
// 小窗一开,浮标就换成「❝ 引用」那一套(解读浮标收掉,引用浮标等新选区)
|
|
3460
|
+
hideButton()
|
|
3461
|
+
hideQuoteButton()
|
|
3462
|
+
state.quoteSelection = null
|
|
2039
3463
|
panel.style.display = 'flex'
|
|
2040
3464
|
panel.style.left = '0px'
|
|
2041
3465
|
panel.style.top = '0px'
|
|
3466
|
+
// 面板刚显示出来才量得到 scrollHeight:草稿里原来的几行要立刻撑开
|
|
3467
|
+
autoGrowAskBox()
|
|
2042
3468
|
var width = panel.offsetWidth
|
|
2043
3469
|
var height = panel.offsetHeight
|
|
2044
3470
|
var left = anchor ? anchor.left : window.innerWidth / 2 - width / 2
|
|
@@ -2057,6 +3483,12 @@ window.__ModuleLoader__.load({
|
|
|
2057
3483
|
panelOpen = false
|
|
2058
3484
|
panel.style.display = 'none'
|
|
2059
3485
|
hideHistoryList()
|
|
3486
|
+
// 小窗收起来了:引用浮标跟着走(它只在"小窗开着"时有意义)
|
|
3487
|
+
hideQuoteButton()
|
|
3488
|
+
state.quoteSelection = null
|
|
3489
|
+
// 正在录音/识别就到此为止:麦**一定要松开**(不能让标签页一直显示"正在使用麦克风"),
|
|
3490
|
+
// 识别出来的文字也没地方放了 —— 连同提示一起静默收掉。
|
|
3491
|
+
cancelVoice()
|
|
2060
3492
|
// 收起小窗不该被记成"失败":打个标记,让 fail() 走「已停止」而不是「解读失败」
|
|
2061
3493
|
if (state.request) state.aborted = true
|
|
2062
3494
|
if (state.request) state.request.abort()
|
|
@@ -2190,7 +3622,7 @@ window.__ModuleLoader__.load({
|
|
|
2190
3622
|
// 配色按**面板实际底色**选(不看系统偏好):主题实现方式怎么变都不影响
|
|
2191
3623
|
layer.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
|
|
2192
3624
|
panel.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
|
|
2193
|
-
askRow.style.display = wide || translationReady ? '' : 'none'
|
|
3625
|
+
askRow.style.display = wide || translationReady || state.quotes.length > 0 ? '' : 'none'
|
|
2194
3626
|
// 展开 CTA:翻译还在跑(含思考期)时先不出现——那时候该看的是等待特效,
|
|
2195
3627
|
// 摆在下面只会是个灰着的按钮,反而像"没反应";发过追问之后也收起(见 syncExpandCta)。
|
|
2196
3628
|
syncExpandCta()
|
|
@@ -2238,36 +3670,115 @@ window.__ModuleLoader__.load({
|
|
|
2238
3670
|
refreshAskState()
|
|
2239
3671
|
paintPill()
|
|
2240
3672
|
keepInsideViewport()
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
/** 内容变高后把面板拉回视口内。 */
|
|
2244
|
-
function keepInsideViewport() {
|
|
2245
|
-
if (!panelOpen) return
|
|
2246
|
-
var rect = panel.getBoundingClientRect()
|
|
2247
|
-
var width = Math.min(rect.width, window.innerWidth - 12)
|
|
2248
|
-
var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
|
|
2249
|
-
var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
|
|
2250
|
-
panel.style.left = Math.round(left) + 'px'
|
|
2251
|
-
panel.style.top = Math.round(top) + 'px'
|
|
3673
|
+
focusComposerOnce()
|
|
2252
3674
|
}
|
|
2253
3675
|
|
|
2254
3676
|
/**
|
|
2255
|
-
*
|
|
3677
|
+
* 开窗后**只聚焦一次**输入框(键盘用户不用先 Tab 两下)。
|
|
2256
3678
|
*
|
|
2257
|
-
*
|
|
2258
|
-
*
|
|
2259
|
-
*
|
|
3679
|
+
* 三个克制的地方:
|
|
3680
|
+
* · 等输入框真的可见才聚焦(首轮翻译还在跑时它是藏着的,聚焦藏着的元素没意义);
|
|
3681
|
+
* · 用户已经在别处打字(主会话输入框、侧边栏…)就**不抢**焦点,并就此作罢;
|
|
3682
|
+
* · preventScroll:别因为聚焦把页面滚一下(划完词页面跳走最难受)。
|
|
2260
3683
|
*/
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
3684
|
+
function focusComposerOnce() {
|
|
3685
|
+
if (!askFocusPending || !panelOpen) return
|
|
3686
|
+
if (!composerVisible()) return
|
|
3687
|
+
// 小窗里正有一段选中文字(用户正在划词、准备点「❝ 引用」):**这一下聚焦会把选区清掉**。
|
|
3688
|
+
// 让位给用户,并且不再补焦 —— 引用浮标是这个功能的主入口,不能因为抢焦点而让它消失。
|
|
3689
|
+
//(实测:开窗后那一帧恰好落在"首轮刚出完"的自动聚焦上,划好的词会瞬间没了。)
|
|
3690
|
+
if (hasPanelSelection()) {
|
|
3691
|
+
askFocusPending = false
|
|
3692
|
+
return
|
|
3693
|
+
}
|
|
3694
|
+
var active = document.activeElement
|
|
3695
|
+
var inside = active && (active === panel || active === askBox || (panel.contains && panel.contains(active)))
|
|
3696
|
+
// 只有"人家正在别处打字"才值得让路。
|
|
3697
|
+
// 注意别把普通的按钮焦点也算进去:点浮标时焦点正好落在浮标那个 <button> 上
|
|
3698
|
+
//(Chrome 点按钮会给它焦点),照"非 body 就不抢"的老写法会把我们自己挡在门外 ——
|
|
3699
|
+
// 这正是"开窗后没有自动聚焦"的原因。
|
|
3700
|
+
if (!inside && isTypingTarget(active)) {
|
|
3701
|
+
askFocusPending = false // 人家真的在别处打字,这次开窗就不再抢
|
|
3702
|
+
return
|
|
3703
|
+
}
|
|
3704
|
+
askFocusPending = false
|
|
3705
|
+
focusAskBox()
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
/** 小窗正文里这会儿有没有一段活选区(划词中/选好了还没点引用)。 */
|
|
3709
|
+
function hasPanelSelection() {
|
|
3710
|
+
try {
|
|
3711
|
+
var selection = window.getSelection && window.getSelection()
|
|
3712
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false
|
|
3713
|
+
var range = selection.getRangeAt(0)
|
|
3714
|
+
return insidePanelContent(range.startContainer) || insidePanelContent(range.endContainer)
|
|
3715
|
+
} catch (error) {
|
|
3716
|
+
return false
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
/** 正在打字的地方(input / textarea / 可编辑区)—— 只有这种焦点值得让路。 */
|
|
3721
|
+
function isTypingTarget(node) {
|
|
3722
|
+
if (!node || node === askBox) return false
|
|
3723
|
+
var tag = String(node.tagName || '').toLowerCase()
|
|
3724
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
|
|
3725
|
+
return node.isContentEditable === true
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
/** 把光标交给输入框(preventScroll:别因为聚焦把页面滚一下)。 */
|
|
3729
|
+
function focusAskBox() {
|
|
3730
|
+
try {
|
|
3731
|
+
askBox.focus({ preventScroll: true })
|
|
3732
|
+
} catch (error) {
|
|
3733
|
+
try {
|
|
3734
|
+
askBox.focus()
|
|
3735
|
+
} catch (ignored) {
|
|
3736
|
+
/* 桩环境 */
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
/** 输入框这会儿真的看得见吗(藏着的元素聚焦没有意义)。 */
|
|
3742
|
+
function composerVisible() {
|
|
3743
|
+
if (!askRow || askRow.style.display === 'none') return false
|
|
3744
|
+
if (typeof askBox.getBoundingClientRect !== 'function') return true
|
|
3745
|
+
try {
|
|
3746
|
+
var rect = askBox.getBoundingClientRect()
|
|
3747
|
+
if (rect && rect.width === 0 && rect.height === 0) return false
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
/* noop */
|
|
3750
|
+
}
|
|
3751
|
+
return true
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
/** 内容变高后把面板拉回视口内。 */
|
|
3755
|
+
function keepInsideViewport() {
|
|
3756
|
+
if (!panelOpen) return
|
|
3757
|
+
var rect = panel.getBoundingClientRect()
|
|
3758
|
+
var width = Math.min(rect.width, window.innerWidth - 12)
|
|
3759
|
+
var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
|
|
3760
|
+
var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
|
|
3761
|
+
panel.style.left = Math.round(left) + 'px'
|
|
3762
|
+
panel.style.top = Math.round(top) + 'px'
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
/**
|
|
3766
|
+
* 剥掉模型伪造的工具调用残渣。
|
|
3767
|
+
*
|
|
3768
|
+
* 没有工具可用时,模型会照着自己的习惯把调用"写"成正文(实测见过
|
|
3769
|
+
* `<ds_safety_tool_call><tool_name>web_search</tool_name>…` 整段喷出来)。
|
|
3770
|
+
* 这里在**渲染时**清理(不改 state.raw,流式期间半截标签也不会丢内容)。
|
|
3771
|
+
*/
|
|
3772
|
+
/**
|
|
3773
|
+
* ── 历史折叠 ──────────────────────────────────────────────────────────
|
|
3774
|
+
* 追问时历史是"原文逐字带上",于是整页 HTML(实测 8k 字 ≈ 2,150 tokens)
|
|
3775
|
+
* 会在之后**每一轮**重发一次,还占掉 20 条窗口里的位置。
|
|
3776
|
+
*
|
|
3777
|
+
* 这里把它折叠成**结构化 Markdown**(标题/表格行列/列表/强调/代码都保留,
|
|
3778
|
+
* 只丢 <style>、class、内联 style、SVG 路径这些"渲染实现细节"),
|
|
3779
|
+
* 实测:8,021 字 → 1,146 字,可见文本覆盖率 100.0%,≈2,150 → ≈860 tokens。
|
|
3780
|
+
*
|
|
3781
|
+
* 三条边界:
|
|
2271
3782
|
* ① 只作用于**发历史**这一步 —— 面板里渲染的、代码视图复制的永远是原文;
|
|
2272
3783
|
* ② 折一次就缓存在那一轮(`turn.foldedCache`),之后每轮直接复用,不重折;
|
|
2273
3784
|
* ③ **最近一轮保持原样** —— 用户说"把上一页的高亮改成蓝色"时仍能改到版式。
|
|
@@ -2596,6 +4107,9 @@ window.__ModuleLoader__.load({
|
|
|
2596
4107
|
*/
|
|
2597
4108
|
function historyTextOf(turn, keepRaw) {
|
|
2598
4109
|
if (!turn) return ''
|
|
4110
|
+
// 用户那轮:发出去的是什么就带回什么(带引用时是拼好的那份,见 composeQuestion)。
|
|
4111
|
+
// 引用块必须进历史 —— 否则下一轮模型只看到"上面那段呢?",而"那段"已经在上下文里消失了。
|
|
4112
|
+
if (turn.role === 'user') return sentTextOf(turn)
|
|
2599
4113
|
if (keepRaw) return turn.text
|
|
2600
4114
|
if (typeof turn.foldedCache === 'string') return turn.foldedCache
|
|
2601
4115
|
if (turn.streaming === true) return turn.text
|
|
@@ -2658,10 +4172,12 @@ window.__ModuleLoader__.load({
|
|
|
2658
4172
|
var payload = state.payload
|
|
2659
4173
|
if (!payload || !state.cacheKey) return
|
|
2660
4174
|
var turns = []
|
|
4175
|
+
var exported = exportTurns()
|
|
2661
4176
|
for (var i = 0; i < state.turns.length; i += 1) {
|
|
2662
4177
|
var turn = state.turns[i]
|
|
2663
4178
|
if (turn.hidden === true) continue
|
|
2664
|
-
|
|
4179
|
+
// 落盘的是"实际发出去的那份"(用户那轮带引用块):回放出来时这轮对话是自洽的
|
|
4180
|
+
turns.push(exported[i])
|
|
2665
4181
|
}
|
|
2666
4182
|
try {
|
|
2667
4183
|
fetch(HISTORY, {
|
|
@@ -2690,6 +4206,8 @@ window.__ModuleLoader__.load({
|
|
|
2690
4206
|
var parts = entry.parts || {}
|
|
2691
4207
|
state.parts = { translation: parts.translation || '', detail: parts.detail || '' }
|
|
2692
4208
|
state.turns = Array.isArray(entry.turns) ? entry.turns.slice() : []
|
|
4209
|
+
// 回放的是**另一段**对话:输入框里攒的引用跟着清掉
|
|
4210
|
+
clearQuotes()
|
|
2693
4211
|
state.toolDigest = typeof entry.toolDigest === 'string' ? entry.toolDigest : ''
|
|
2694
4212
|
state.cacheKey = entry.key
|
|
2695
4213
|
state.payload = buildPayload(entry.text, entry.context, entry.label)
|
|
@@ -3114,7 +4632,12 @@ window.__ModuleLoader__.load({
|
|
|
3114
4632
|
if (chatLog.children.length !== visible.length) {
|
|
3115
4633
|
clearWaiting(chatLog)
|
|
3116
4634
|
chatLog.textContent = ''
|
|
3117
|
-
for (var j = 0; j < visible.length; j++)
|
|
4635
|
+
for (var j = 0; j < visible.length; j++) {
|
|
4636
|
+
var bubble = bubbleFor(visible[j])
|
|
4637
|
+
// 气泡记下自己属于哪一轮:引用时要据此取"这一轮 ± 一轮"的上下文
|
|
4638
|
+
bubble.__turn = visible[j]
|
|
4639
|
+
chatLog.appendChild(bubble)
|
|
4640
|
+
}
|
|
3118
4641
|
// 刚入列的"待答"气泡:此时才挂进 DOM,可以画等待特效了
|
|
3119
4642
|
var fresh = visible[visible.length - 1]
|
|
3120
4643
|
if (fresh && fresh.role === 'assistant' && fresh.asking === true && !fresh.text) {
|
|
@@ -3130,7 +4653,14 @@ window.__ModuleLoader__.load({
|
|
|
3130
4653
|
var turn = visible[visible.length - 1]
|
|
3131
4654
|
var painted = false
|
|
3132
4655
|
node.className = 'dsh-sel-bubble ' + (turn.role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot') + (turn.error ? ' dsh-sel-bubble-err' : '')
|
|
3133
|
-
if (turn.role === 'user'
|
|
4656
|
+
if (turn.role === 'user') {
|
|
4657
|
+
// 用户气泡可能带引用块:签名包含引用,别每次重绘都白重建一遍
|
|
4658
|
+
var usig = (turn.text || '') + '|' + (turn.quotes || []).map(function (item) { return item.text }).join('\u0000')
|
|
4659
|
+
if (node.__sig !== usig) {
|
|
4660
|
+
node.__sig = usig
|
|
4661
|
+
fillUserBubble(node, turn)
|
|
4662
|
+
}
|
|
4663
|
+
} else if (turn.error) {
|
|
3134
4664
|
node.textContent = turn.text
|
|
3135
4665
|
} else if (turn.asking === true && !turn.text) {
|
|
3136
4666
|
// 追问等待期:和首轮同一套等待特效(呼吸点 + 实时秒数 + 💭 思考尾巴)
|
|
@@ -3150,9 +4680,11 @@ window.__ModuleLoader__.load({
|
|
|
3150
4680
|
renderRich(node, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
|
|
3151
4681
|
}
|
|
3152
4682
|
paintNotice(node, turn)
|
|
4683
|
+
paintQuoteAll(node, turn)
|
|
3153
4684
|
painted = true
|
|
3154
4685
|
}
|
|
3155
4686
|
if (!painted && turn.role !== 'user') paintNotice(node, turn)
|
|
4687
|
+
if (!painted && turn.role !== 'user') paintQuoteAll(node, turn)
|
|
3156
4688
|
}
|
|
3157
4689
|
// 可见性必须在这里定:追问路径只调 renderTurns(),不会触发 paint(),
|
|
3158
4690
|
// 之前把它挪进 paint() 导致"发送后气泡画进了 display:none 的容器里,小窗没反应"
|
|
@@ -3178,20 +4710,83 @@ window.__ModuleLoader__.load({
|
|
|
3178
4710
|
existing.textContent = text
|
|
3179
4711
|
}
|
|
3180
4712
|
|
|
4713
|
+
/**
|
|
4714
|
+
* 用户气泡:有引用时**先画引用块、再画问题**(和发给模型的那份顺序一致)。
|
|
4715
|
+
* 只写 textContent 的话引用就看不见了 —— 模型答的是"上面那段",
|
|
4716
|
+
* 而"上面那段"在气泡里根本不存在,回头看会一头雾水。
|
|
4717
|
+
*/
|
|
4718
|
+
function fillUserBubble(node, turn) {
|
|
4719
|
+
node.textContent = ''
|
|
4720
|
+
var quotes = turn.quotes || []
|
|
4721
|
+
if (quotes.length > 0) {
|
|
4722
|
+
var box = el('div', 'dsh-sel-bq')
|
|
4723
|
+
for (var i = 0; i < quotes.length; i += 1) {
|
|
4724
|
+
var item = el('div', 'dsh-sel-bqitem')
|
|
4725
|
+
item.appendChild(el('b', null, '❝ ' + quoteLabelOf(quotes[i])))
|
|
4726
|
+
item.appendChild(document.createTextNode(quoteBrief(quotes[i].text, 60)))
|
|
4727
|
+
item.title = quotes[i].text
|
|
4728
|
+
box.appendChild(item)
|
|
4729
|
+
}
|
|
4730
|
+
node.appendChild(box)
|
|
4731
|
+
}
|
|
4732
|
+
if (turn.text) node.appendChild(el('div', null, turn.text))
|
|
4733
|
+
}
|
|
4734
|
+
|
|
3181
4735
|
function bubbleFor(turn) {
|
|
3182
4736
|
var bubble = el('div', 'dsh-sel-bubble ' + (turn.role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot'))
|
|
3183
4737
|
if (turn.role === 'user') {
|
|
3184
|
-
bubble
|
|
4738
|
+
fillUserBubble(bubble, turn)
|
|
3185
4739
|
} else if (turn.error) {
|
|
3186
4740
|
bubble.className += ' dsh-sel-bubble-err'
|
|
3187
4741
|
bubble.textContent = turn.text
|
|
3188
4742
|
} else {
|
|
3189
4743
|
renderRich(bubble, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
|
|
3190
4744
|
}
|
|
3191
|
-
if (turn.role !== 'user')
|
|
4745
|
+
if (turn.role !== 'user') {
|
|
4746
|
+
paintNotice(bubble, turn)
|
|
4747
|
+
paintQuoteAll(bubble, turn)
|
|
4748
|
+
}
|
|
3192
4749
|
return bubble
|
|
3193
4750
|
}
|
|
3194
4751
|
|
|
4752
|
+
/**
|
|
4753
|
+
* 助手气泡末尾的「❝ 引用整条」(鼠标移到这条消息上才浮出来)。
|
|
4754
|
+
*
|
|
4755
|
+
* 每次重画都要补一遍:renderRich() 会先清空气泡,按钮跟着一起没了。
|
|
4756
|
+
* 点的时候现读 `source.text`(一轮还在流式时文本一直在长),并折掉网页回答里的 HTML
|
|
4757
|
+
* —— 引用一整页 HTML 源码既长又没有提问价值,历史里本来也是折成 Markdown 的。
|
|
4758
|
+
*/
|
|
4759
|
+
function paintQuoteAll(node, turn) {
|
|
4760
|
+
var existing = null
|
|
4761
|
+
for (var i = 0; i < node.children.length; i += 1) {
|
|
4762
|
+
if (String(node.children[i].className).indexOf('dsh-sel-bubquote') >= 0) existing = node.children[i]
|
|
4763
|
+
}
|
|
4764
|
+
var ready = turn && turn.error !== true && turn.streaming !== true && String(turn.text || '').trim()
|
|
4765
|
+
if (!ready) {
|
|
4766
|
+
if (existing) node.removeChild(existing)
|
|
4767
|
+
return
|
|
4768
|
+
}
|
|
4769
|
+
if (!existing) {
|
|
4770
|
+
existing = el('button', 'dsh-sel-bubquote')
|
|
4771
|
+
existing.type = 'button'
|
|
4772
|
+
existing.appendChild(el('span', null, '❝ 引用整条'))
|
|
4773
|
+
node.appendChild(existing)
|
|
4774
|
+
}
|
|
4775
|
+
existing.title = '把这一整条回答加进追问的引用'
|
|
4776
|
+
existing.__turn = turn
|
|
4777
|
+
if (existing.__wired !== true) {
|
|
4778
|
+
existing.__wired = true
|
|
4779
|
+
listen(existing, 'click', function (event) {
|
|
4780
|
+
event.stopPropagation()
|
|
4781
|
+
var source = existing.__turn
|
|
4782
|
+
if (!source) return
|
|
4783
|
+
var body = foldForHistory(sanitizeToolResidue(source.text))
|
|
4784
|
+
// 整条引用同样不带上下文:出处写进标签(「小窗第 N 轮回答」),对话本身在历史里
|
|
4785
|
+
addQuote(body, turnLabelOf(source), { context: '', session: false })
|
|
4786
|
+
})
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
|
|
3195
4790
|
/** 把首轮解读结果作为追问的第一条助手上下文(截断,避免过长)。 */
|
|
3196
4791
|
function seedHistoryFromExplanation() {
|
|
3197
4792
|
var fallback = splitSections(sanitizeToolResidue(state.raw))
|
|
@@ -3218,7 +4813,39 @@ window.__ModuleLoader__.load({
|
|
|
3218
4813
|
* 发送键 = 「发送」还是「停止」:生成中变成方形停止键(点了打断这次输出),
|
|
3219
4814
|
* 空闲时是上箭头发送键(空输入才禁用)。
|
|
3220
4815
|
*/
|
|
4816
|
+
/**
|
|
4817
|
+
* 输入框跟着内容长高(到 132px 为止,再高就内部滚动)。
|
|
4818
|
+
*
|
|
4819
|
+
* textarea 的**高度由 rows 决定**,内容换行它不会自己长 —— 不补这一步,第二行起
|
|
4820
|
+
* 下面的字就被裁掉了。语音输入的实时字幕最吃亏:字是边说边自己长出来的,
|
|
4821
|
+
* 用户根本没在敲键盘,也就不会想到去拖那个框。
|
|
4822
|
+
* 超过上限时把最新那几行滚进视野(实时落字时光标一直在末尾)。
|
|
4823
|
+
*/
|
|
4824
|
+
function autoGrowAskBox() {
|
|
4825
|
+
if (!askBox || !askBox.style) return
|
|
4826
|
+
var cap = 132
|
|
4827
|
+
// 先松开高度再量,否则量到的是上一次的高度(经典递归式变高)
|
|
4828
|
+
askBox.style.height = 'auto'
|
|
4829
|
+
var content = typeof askBox.scrollHeight === 'number' ? askBox.scrollHeight : 0
|
|
4830
|
+
if (content > 0) {
|
|
4831
|
+
askBox.style.height = Math.min(content, cap) + 'px'
|
|
4832
|
+
askBox.style.overflowY = content > cap ? 'auto' : 'hidden'
|
|
4833
|
+
} else {
|
|
4834
|
+
// 量不到内容(面板收着 / DOM 桩):交回 CSS 的 min-height
|
|
4835
|
+
askBox.style.height = ''
|
|
4836
|
+
askBox.style.overflowY = ''
|
|
4837
|
+
}
|
|
4838
|
+
try {
|
|
4839
|
+
if (content > cap) askBox.scrollTop = askBox.scrollHeight
|
|
4840
|
+
} catch (error) {
|
|
4841
|
+
/* 桩环境没有 scrollTop 语义 */
|
|
4842
|
+
}
|
|
4843
|
+
// 长高之后面板可能顶出视口:拉回来(面板没开时它自己会早退)
|
|
4844
|
+
keepInsideViewport()
|
|
4845
|
+
}
|
|
4846
|
+
|
|
3221
4847
|
function refreshAskState() {
|
|
4848
|
+
autoGrowAskBox()
|
|
3222
4849
|
var generating = isGenerating()
|
|
3223
4850
|
var mode = generating ? 'stop' : 'send'
|
|
3224
4851
|
if (askSend.getAttribute('data-mode') !== mode) {
|
|
@@ -3228,7 +4855,7 @@ window.__ModuleLoader__.load({
|
|
|
3228
4855
|
askSend.setAttribute('aria-label', generating ? '停止' : '发送')
|
|
3229
4856
|
askSend.title = generating ? '停止生成(点一下打断这次输出)' : '发送(Enter)'
|
|
3230
4857
|
}
|
|
3231
|
-
askSend.disabled = generating ? false : !String(askBox.value || '').trim()
|
|
4858
|
+
askSend.disabled = generating ? false : !String(askBox.value || '').trim() && state.quotes.length === 0
|
|
3232
4859
|
}
|
|
3233
4860
|
|
|
3234
4861
|
/** 停止当前这一轮:追问走自己的 abort;首轮/详解复用"用户中止"那条路径。 */
|
|
@@ -3255,7 +4882,13 @@ window.__ModuleLoader__.load({
|
|
|
3255
4882
|
return false
|
|
3256
4883
|
}
|
|
3257
4884
|
|
|
3258
|
-
/**
|
|
4885
|
+
/**
|
|
4886
|
+
* 发送一个追问(多轮,带历史;走 host 的 chat 模式)。
|
|
4887
|
+
*
|
|
4888
|
+
* 两段式:先**取引用的上下文**(会话里那几段要问 host 要,见 resolveQuoteContexts),
|
|
4889
|
+
* 再真正发。取上下文期间用 state.resolvingQuotes 挡住重复发送 ——
|
|
4890
|
+
* 那一刻输入框和引用区都已经清空了,再按一次 Enter 会变成"空发一条"。
|
|
4891
|
+
*/
|
|
3259
4892
|
function ask(question) {
|
|
3260
4893
|
var generating = state.asking || state.phase === 'loading' || state.phase === 'streaming'
|
|
3261
4894
|
if (generating) {
|
|
@@ -3263,12 +4896,47 @@ window.__ModuleLoader__.load({
|
|
|
3263
4896
|
setStatus('还在生成,请稍候…(生成完就能追问)')
|
|
3264
4897
|
return
|
|
3265
4898
|
}
|
|
3266
|
-
|
|
4899
|
+
// 这条消息发出去了,正在录的那一段就不再是"下一条提问"了:
|
|
4900
|
+
// 收掉它可以避免"发送后麦克风还红着"(用户会以为还在录)。
|
|
4901
|
+
// 已经识别出来的文字在输入框里,不受影响。
|
|
4902
|
+
if (voice.phase === 'recording' || voice.phase === 'requesting' || voice.phase === 'transcribing') {
|
|
4903
|
+
cancelVoice({ note: '已停止录音(这条先发出去)' })
|
|
4904
|
+
}
|
|
4905
|
+
var asked = String(question || '').trim()
|
|
4906
|
+
// 引用先取出来:只挂引用、没写问题也允许发(这时用一句兜底提问)
|
|
4907
|
+
var quotes = state.quotes.slice()
|
|
4908
|
+
if ((!asked && quotes.length === 0) || !state.payload) return
|
|
4909
|
+
if (state.resolvingQuotes) {
|
|
4910
|
+
setStatus('正在取引用的上下文…')
|
|
4911
|
+
return
|
|
4912
|
+
}
|
|
4913
|
+
// 引用是"下一条提问"的:立刻清空(提问已经在手上,别让下一轮又带上)
|
|
4914
|
+
if (quotes.length > 0) {
|
|
4915
|
+
state.quotes = []
|
|
4916
|
+
renderQuotes()
|
|
4917
|
+
refreshAskState()
|
|
4918
|
+
}
|
|
4919
|
+
if (quotes.length === 0) {
|
|
4920
|
+
sendAsk(asked, quotes)
|
|
4921
|
+
return
|
|
4922
|
+
}
|
|
4923
|
+
state.resolvingQuotes = true
|
|
4924
|
+
resolveQuoteContexts(quotes).then(function (resolved) {
|
|
4925
|
+
state.resolvingQuotes = false
|
|
4926
|
+
sendAsk(asked, resolved)
|
|
4927
|
+
})
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
/** 真正把这一轮发出去(引用已经带上上下文了)。 */
|
|
4931
|
+
function sendAsk(asked, quotes) {
|
|
3267
4932
|
var payload = state.payload
|
|
4933
|
+
if (!payload) return
|
|
4934
|
+
// 引用拼进**这一条**消息:发给模型、进历史、升格用的都是拼好的那份
|
|
4935
|
+
var sent = composeQuestion(quotes, asked)
|
|
3268
4936
|
if (state.turns.length === 0 && (state.raw.trim() || state.parts.translation || state.parts.detail)) {
|
|
3269
4937
|
state.turns.push({ role: 'assistant', text: seedHistoryFromExplanation(), seed: true, hidden: true })
|
|
3270
4938
|
}
|
|
3271
|
-
state.turns.push({ role: 'user', text:
|
|
4939
|
+
state.turns.push({ role: 'user', text: asked || QUOTE_ONLY_QUESTION, quotes: quotes, sent: sent })
|
|
3272
4940
|
// asking 只管"等待特效要不要显示"(首字一到就置 false),
|
|
3273
4941
|
// streaming 才是"这一轮还没写完"。两者必须分开:以前拿 asking 当定稿判据,
|
|
3274
4942
|
// 结果是首字一到就认定"定稿了"→ 每个 delta 都重建一个 iframe(实测一条回答建了 24 个),
|
|
@@ -3365,7 +5033,7 @@ window.__ModuleLoader__.load({
|
|
|
3365
5033
|
context: payload.context,
|
|
3366
5034
|
label: payload.label,
|
|
3367
5035
|
sessionId: currentSessionId(),
|
|
3368
|
-
question:
|
|
5036
|
+
question: sent,
|
|
3369
5037
|
history: history,
|
|
3370
5038
|
...(state.webAnswer ? { webAnswer: true } : {}),
|
|
3371
5039
|
...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
|
|
@@ -3857,7 +5525,8 @@ window.__ModuleLoader__.load({
|
|
|
3857
5525
|
label: payload.label,
|
|
3858
5526
|
translation: (sections.translation || '').trim(),
|
|
3859
5527
|
detail: (sections.detail || '').trim(),
|
|
3860
|
-
|
|
5528
|
+
// 带引用块的轮次:升格出来的会话里,用户当时发的就是拼好的那份
|
|
5529
|
+
turns: exportTurns(),
|
|
3861
5530
|
}),
|
|
3862
5531
|
})
|
|
3863
5532
|
.then(function (response) {
|
|
@@ -3934,8 +5603,15 @@ window.__ModuleLoader__.load({
|
|
|
3934
5603
|
setButtonPop(false)
|
|
3935
5604
|
})
|
|
3936
5605
|
|
|
5606
|
+
var offQuotePopEnd = listen(quoteButton, 'animationend', function () {
|
|
5607
|
+
setQuotePop(false)
|
|
5608
|
+
})
|
|
5609
|
+
|
|
3937
5610
|
var offMouseUp = listen(document, 'mouseup', function (event) {
|
|
3938
|
-
|
|
5611
|
+
// 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
|
|
5612
|
+
// (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
|
|
5613
|
+
// 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
|
|
5614
|
+
if (button.contains(event.target) || quoteButton.contains(event.target)) return
|
|
3939
5615
|
scheduleCheck()
|
|
3940
5616
|
}, true)
|
|
3941
5617
|
|
|
@@ -3956,6 +5632,7 @@ window.__ModuleLoader__.load({
|
|
|
3956
5632
|
if (historyList.contains(target)) return // 列表内部:行自己处理
|
|
3957
5633
|
if (historyButton.contains(target)) return // 「最近」开关自己 toggle
|
|
3958
5634
|
if (button.contains(target)) return // 浮标
|
|
5635
|
+
if (quoteButton.contains(target)) return // 引用浮标:点它不能先把自己收掉
|
|
3959
5636
|
// 状态胶囊:它自己就是开关,点它的 mousedown 不能被当成"点了外面"(否则先关后开,看着像没反应)
|
|
3960
5637
|
if (pill.contains(target)) return
|
|
3961
5638
|
// 到这里说明点的不是侧边栏本身:**含面板正文**(消息区/输入框/选中文字)在内,
|
|
@@ -3963,6 +5640,7 @@ window.__ModuleLoader__.load({
|
|
|
3963
5640
|
hideHistoryList()
|
|
3964
5641
|
if (panel.contains(target)) return // 面板内的点击只收侧边栏,不关面板
|
|
3965
5642
|
hideButton()
|
|
5643
|
+
hideQuoteButton()
|
|
3966
5644
|
// 点面板外**不关小窗**(无论有没有追问过):答案留在原地,只有 ✕ / Esc / 胶囊能收。
|
|
3967
5645
|
// 曾经的规则是"没追问过才关",但那会让用户在读第一屏翻译时被误关(点一下别处就没了)。
|
|
3968
5646
|
}, true)
|
|
@@ -3980,8 +5658,26 @@ window.__ModuleLoader__.load({
|
|
|
3980
5658
|
if (selection) openForSelection(selection)
|
|
3981
5659
|
})
|
|
3982
5660
|
|
|
5661
|
+
// 引用浮标:吞掉 mousedown(点了不能把选区打散、也不能让面板失焦),
|
|
5662
|
+
// 点一下 = 把这段文字挂进输入框的引用区,然后自己收起来("已经进去了"的信号)
|
|
5663
|
+
var offQuoteDown = listen(quoteButton, 'mousedown', function (event) {
|
|
5664
|
+
event.preventDefault()
|
|
5665
|
+
event.stopPropagation()
|
|
5666
|
+
})
|
|
5667
|
+
|
|
5668
|
+
var offQuoteClick = listen(quoteButton, 'click', function (event) {
|
|
5669
|
+
event.preventDefault()
|
|
5670
|
+
event.stopPropagation()
|
|
5671
|
+
var selection = state.quoteSelection
|
|
5672
|
+
if (!selection) return
|
|
5673
|
+
// 上下文在这里才采:引用**当时**在哪一段对话里,是这条引用最有用的信息
|
|
5674
|
+
addQuote(selection.text, selection.label, quoteContextFor(selection))
|
|
5675
|
+
hideQuoteButton()
|
|
5676
|
+
})
|
|
5677
|
+
|
|
3983
5678
|
var offScroll = listen(window, 'scroll', function (event) {
|
|
3984
5679
|
hideButton()
|
|
5680
|
+
hideQuoteButton()
|
|
3985
5681
|
// 页面/面板滚动也收回侧边栏;滚动列表自身不算(它有自己的滚动条)。
|
|
3986
5682
|
// event.target 不一定是 Node(合成事件可能是 window),contains 会抛,得自己挡一道
|
|
3987
5683
|
var target = event && event.target
|
|
@@ -4312,6 +6008,8 @@ window.__ModuleLoader__.load({
|
|
|
4312
6008
|
.then(function (data) {
|
|
4313
6009
|
modelCatalog.current = (data && data.route) || null
|
|
4314
6010
|
modelCatalog.stages = { chat: (data && data.reasoningEffortByStage && data.reasoningEffortByStage.chat) || '' }
|
|
6011
|
+
// 侧边栏网页划词桥的总开关(host 配置 bridgeSidebarPreview,默认开)
|
|
6012
|
+
if (data && data.bridgeSidebarPreview === false) bridgeOn = false
|
|
4315
6013
|
paintModelPill()
|
|
4316
6014
|
return null
|
|
4317
6015
|
})
|
|
@@ -4389,6 +6087,9 @@ window.__ModuleLoader__.load({
|
|
|
4389
6087
|
|
|
4390
6088
|
var offResize = listen(window, 'resize', function () {
|
|
4391
6089
|
hideButton()
|
|
6090
|
+
hideQuoteButton()
|
|
6091
|
+
// 宽度变了 → 换行位置变了 → 输入框高度要重量(否则会是按旧宽度算出来的高度)
|
|
6092
|
+
autoGrowAskBox()
|
|
4392
6093
|
// 拖窗口变小后,面板本身也要拉回视口内(否则历史列表会跟着算到屏幕外)
|
|
4393
6094
|
keepInsideViewport()
|
|
4394
6095
|
if (historyList.style.display === 'flex') placeHistoryList()
|
|
@@ -4398,13 +6099,20 @@ window.__ModuleLoader__.load({
|
|
|
4398
6099
|
})
|
|
4399
6100
|
|
|
4400
6101
|
var offKeyDown = listen(document, 'keydown', function (event) {
|
|
4401
|
-
|
|
4402
|
-
//
|
|
4403
|
-
|
|
6102
|
+
if (event.key !== 'Escape' || !panelOpen) return
|
|
6103
|
+
// Esc 分两级,和主会话一致:**先退出正在进行的事,再关窗**。
|
|
6104
|
+
// ① 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着),面板留着;
|
|
6105
|
+
// ② 其余情况 → 关掉小窗(一次到位,不受"有没有追问过"限制)。
|
|
6106
|
+
// 以前是一律关窗:正在说一句话时按 Esc 想放弃这句话,结果整个小窗没了。
|
|
6107
|
+
if (captureRow.getAttribute('data-show') === '1') {
|
|
4404
6108
|
event.preventDefault()
|
|
4405
6109
|
event.stopPropagation()
|
|
4406
|
-
|
|
6110
|
+
cancelVoice()
|
|
6111
|
+
return
|
|
4407
6112
|
}
|
|
6113
|
+
event.preventDefault()
|
|
6114
|
+
event.stopPropagation()
|
|
6115
|
+
closePanel()
|
|
4408
6116
|
}, true)
|
|
4409
6117
|
|
|
4410
6118
|
var offHistory = listen(historyButton, 'click', function (event) {
|
|
@@ -4435,7 +6143,8 @@ window.__ModuleLoader__.load({
|
|
|
4435
6143
|
event.preventDefault()
|
|
4436
6144
|
event.stopPropagation()
|
|
4437
6145
|
var question = askBox.value
|
|
4438
|
-
|
|
6146
|
+
// 空输入框照发**只在挂了引用时**成立(引用本身就是用户要送出去的内容)
|
|
6147
|
+
if (!question.trim() && state.quotes.length === 0) return
|
|
4439
6148
|
askBox.value = ''
|
|
4440
6149
|
refreshAskState()
|
|
4441
6150
|
ask(question)
|
|
@@ -4452,108 +6161,1481 @@ window.__ModuleLoader__.load({
|
|
|
4452
6161
|
return
|
|
4453
6162
|
}
|
|
4454
6163
|
var question = askBox.value
|
|
4455
|
-
if (!question.trim()) return
|
|
6164
|
+
if (!question.trim() && state.quotes.length === 0) return
|
|
4456
6165
|
askBox.value = ''
|
|
4457
6166
|
refreshAskState()
|
|
4458
6167
|
ask(question)
|
|
4459
6168
|
})
|
|
4460
6169
|
|
|
4461
|
-
//
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
6170
|
+
// ══════════════════════════ 语音输入(麦克风) ══════════════════════════
|
|
6171
|
+
//
|
|
6172
|
+
// 要解决的问题:追问往往只有一两句话("这词在这里是不是贬义?"),
|
|
6173
|
+
// 但**打字**这件事本身就要把手从鼠标挪到键盘——和"划词"这个动作是矛盾的。
|
|
6174
|
+
//
|
|
6175
|
+
// **UI 与主会话同一套**(官方 @deepseek-ai/dsh-experimental-client-ui-voice-input):
|
|
6176
|
+
// 平时是输入框右边的 🎤;开始录之后工具行换成录音行 ——`✕` | 实时波形 | `■`,
|
|
6177
|
+
// 请求权限/识别中是「呼吸点 + 请允许使用麦克风…/识别中…」,出错是「原因 + 行内动作」。
|
|
6178
|
+
// 波形算法(80 根线 / 50ms 采样 / 1+min(1,amp*5)*17 / 右侧最新)逐行照抄,
|
|
6179
|
+
// 只有尺寸按小窗的 composer 收成 26px(主会话 32px)——两处看起来才是同一个东西。
|
|
6180
|
+
//
|
|
6181
|
+
// 识别出来的文字**插进输入框**(不是直接发送:识别有错字,插进去还能改,
|
|
6182
|
+
// 也能在同一条里接着说第二段)。录音中切走/收起小窗/这条消息发出去/停用插件
|
|
6183
|
+
// 都会立刻松开麦克风(不留下一直亮着的录音标识)。
|
|
4465
6184
|
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
6185
|
+
/** 状态机:idle → requesting → recording → transcribing →(feedback | idle)→ idle。 */
|
|
6186
|
+
var voice = {
|
|
6187
|
+
phase: 'idle',
|
|
6188
|
+
/**
|
|
6189
|
+
* 第几轮。停/取消/关面板都会 +1 —— 晚到的回调据此丢弃自己。
|
|
6190
|
+
* 没有它就会出现"明明取消了,过两秒却冒出一段文字"(异步的权限、识别都可能晚到)。
|
|
6191
|
+
*/
|
|
6192
|
+
generation: 0,
|
|
6193
|
+
capture: null,
|
|
6194
|
+
abort: null,
|
|
6195
|
+
ticker: 0,
|
|
6196
|
+
waveFrame: 0,
|
|
6197
|
+
waveAt: 0,
|
|
6198
|
+
startedAt: 0,
|
|
6199
|
+
maxSeconds: VOICE_FALLBACK_SECONDS,
|
|
6200
|
+
catalog: null,
|
|
6201
|
+
catalogAt: 0,
|
|
6202
|
+
idleTimer: 0,
|
|
6203
|
+
pollTimer: 0,
|
|
6204
|
+
lastClock: -1,
|
|
6205
|
+
level: 0,
|
|
6206
|
+
lastText: '',
|
|
6207
|
+
/** feedback 阶段的行内动作(null = 只显示原因,不给按钮)。 */
|
|
6208
|
+
action: null,
|
|
6209
|
+
/**
|
|
6210
|
+
* 实时字幕(半句预览 + 停顿定稿)的运行时状态。
|
|
6211
|
+
* text/committed/preview 都是输入框里那一段的一部分,start 是它在 value 里的起点。
|
|
6212
|
+
*/
|
|
6213
|
+
live: {
|
|
6214
|
+
active: false,
|
|
6215
|
+
disabled: false,
|
|
6216
|
+
reason: '',
|
|
6217
|
+
separator: '',
|
|
6218
|
+
start: -1,
|
|
6219
|
+
text: '',
|
|
6220
|
+
committed: '',
|
|
6221
|
+
preview: '',
|
|
6222
|
+
boundary: 0,
|
|
6223
|
+
phraseStart: 0,
|
|
6224
|
+
inflight: false,
|
|
6225
|
+
controller: null,
|
|
6226
|
+
timer: 0,
|
|
6227
|
+
tickAt: 0,
|
|
6228
|
+
startedAt: 0,
|
|
6229
|
+
previewFails: 0,
|
|
6230
|
+
passes: { preview: 0, commit: 0 },
|
|
6231
|
+
rewritten: false,
|
|
6232
|
+
},
|
|
6233
|
+
}
|
|
4470
6234
|
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
6235
|
+
/** 录音失败的统一话术(浏览器抛的是 DOMException,用户看不懂)。 */
|
|
6236
|
+
var VOICE_ERRORS = {
|
|
6237
|
+
unavailable: '当前浏览器不支持录音,请使用支持麦克风的浏览器。',
|
|
6238
|
+
permission: '麦克风权限未开启,请在浏览器和系统设置中允许访问。',
|
|
6239
|
+
missing: '没有找到麦克风设备。',
|
|
6240
|
+
interrupted: '录音中断,请重试。',
|
|
6241
|
+
empty: '未识别到语音',
|
|
6242
|
+
cancelled: '已取消语音输入。',
|
|
6243
|
+
}
|
|
6244
|
+
|
|
6245
|
+
function voiceError(kind, extra) {
|
|
6246
|
+
var error = new Error(VOICE_ERRORS[kind] || '录音失败')
|
|
6247
|
+
error.voiceKind = kind
|
|
6248
|
+
if (extra) error.cause = extra
|
|
6249
|
+
return error
|
|
6250
|
+
}
|
|
6251
|
+
|
|
6252
|
+
/** 出错信息 → 一句人话。 */
|
|
6253
|
+
function voiceMessageOf(error) {
|
|
6254
|
+
if (!error) return '未知错误'
|
|
6255
|
+
if (error.voiceKind) return VOICE_ERRORS[error.voiceKind] || '录音失败'
|
|
6256
|
+
return String(error.message || error)
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6259
|
+
/**
|
|
6260
|
+
* 状态行文案:
|
|
6261
|
+
* @param text 文案
|
|
6262
|
+
* @param options { dot: 显示呼吸点, tone: '' | 'warn' | 'error' }
|
|
6263
|
+
*/
|
|
6264
|
+
function setVoiceActivity(text, options) {
|
|
6265
|
+
var opts = options || {}
|
|
6266
|
+
voiceActivityText.textContent = String(text || '')
|
|
6267
|
+
voiceActivity.setAttribute('data-tone', opts.tone || '')
|
|
6268
|
+
voiceDot.style.display = opts.dot ? '' : 'none'
|
|
6269
|
+
}
|
|
6270
|
+
|
|
6271
|
+
/** 行内动作位:'prepare'(准备模型文字键)/ null(用 🎤 重录)/ 'none'(什么都不要)。 */
|
|
6272
|
+
function setVoiceAction(kind) {
|
|
6273
|
+
while (voiceActionSlot.firstChild) voiceActionSlot.removeChild(voiceActionSlot.firstChild)
|
|
6274
|
+
voice.action = kind || null
|
|
6275
|
+
if (kind === 'prepare') {
|
|
6276
|
+
var prepareBtn = el('button', 'dsh-sel-vact', '准备模型')
|
|
6277
|
+
prepareBtn.type = 'button'
|
|
6278
|
+
prepareBtn.title = '在本机下载并准备识别模型(首次要下载,之后一直可用)'
|
|
6279
|
+
listen(prepareBtn, 'click', function (event) {
|
|
6280
|
+
event.stopPropagation()
|
|
6281
|
+
prepareVoice()
|
|
6282
|
+
})
|
|
6283
|
+
voiceActionSlot.appendChild(prepareBtn)
|
|
6284
|
+
return prepareBtn
|
|
6285
|
+
}
|
|
6286
|
+
if (kind === 'retry') {
|
|
6287
|
+
var retryMic = el('button', 'dsh-sel-round')
|
|
6288
|
+
retryMic.type = 'button'
|
|
6289
|
+
retryMic.setAttribute('aria-label', '重新录音')
|
|
6290
|
+
retryMic.title = '重新录音'
|
|
6291
|
+
retryMic.appendChild(micIcon())
|
|
6292
|
+
listen(retryMic, 'click', function (event) {
|
|
6293
|
+
event.stopPropagation()
|
|
6294
|
+
startVoice()
|
|
6295
|
+
})
|
|
6296
|
+
voiceActionSlot.appendChild(retryMic)
|
|
6297
|
+
return retryMic
|
|
4481
6298
|
}
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
6299
|
+
return null
|
|
6300
|
+
}
|
|
6301
|
+
|
|
6302
|
+
/**
|
|
6303
|
+
* 出错/提示:录音行里给一句原因 +(有救的)一个动作。`retryable` 为真时给 🎤 重录键。
|
|
6304
|
+
* 这是主会话的 feedback 阶段:它就一直停在那儿,直到用户点 ✕(或行内动作)。
|
|
6305
|
+
*/
|
|
6306
|
+
function voiceFeedback(text, tone, action) {
|
|
6307
|
+
voice.phase = 'feedback'
|
|
6308
|
+
setVoiceActivity(text, { tone: tone || '' })
|
|
6309
|
+
setVoiceAction(action)
|
|
6310
|
+
paintVoice()
|
|
6311
|
+
}
|
|
6312
|
+
|
|
6313
|
+
/** 按状态摆好录音行:idle 显 🎤,其余显录音行(✕ / 波形或文案 / ■ 或行内动作)。 */
|
|
6314
|
+
function paintVoice() {
|
|
6315
|
+
var phase = voice.phase
|
|
6316
|
+
var capturing = phase !== 'idle'
|
|
6317
|
+
captureRow.setAttribute('data-show', capturing ? '1' : '0')
|
|
6318
|
+
captureRow.style.display = capturing ? 'flex' : 'none'
|
|
6319
|
+
// 录音行出现时,工具行左边那三样(输出偏好 / 模型 / 🎤)让位——和主会话一样,
|
|
6320
|
+
// 录音时那行只讲一件事:怎么结束、结束之后会得到什么。
|
|
6321
|
+
webMode.style.display = capturing ? 'none' : ''
|
|
6322
|
+
askSpacer.style.display = capturing ? 'none' : ''
|
|
6323
|
+
modelPill.style.display = capturing ? 'none' : ''
|
|
6324
|
+
micButton.style.display = capturing ? 'none' : ''
|
|
6325
|
+
// 中间那格:录音时是波形,其余是文案;■ 只在录音时出现
|
|
6326
|
+
var recording = phase === 'recording'
|
|
6327
|
+
waveSvg.style.display = recording ? '' : 'none'
|
|
6328
|
+
voiceActivity.style.display = recording ? 'none' : 'flex'
|
|
6329
|
+
voiceStop.style.display = recording ? 'inline-grid' : 'none'
|
|
6330
|
+
voiceCancel.title = phase === 'feedback' ? '关闭(Esc)' : '取消这次语音(Esc,已经说出来的字留着)'
|
|
6331
|
+
voiceCancel.setAttribute('aria-label', voiceCancel.title)
|
|
6332
|
+
// 🎤 触发键的状态:只在"请求权限"时转圈(其余都是普通图标 —— 和主会话一致)
|
|
6333
|
+
micButton.setAttribute('data-state', phase === 'requesting' ? 'requesting' : 'idle')
|
|
6334
|
+
micButton.setAttribute('aria-pressed', capturing ? 'true' : 'false')
|
|
6335
|
+
micButton.title = '语音输入(点一下开始说,再点一下结束并转成文字)'
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6338
|
+
/** 识别器:目录里默认那个(没有就第一个)。 */
|
|
6339
|
+
function voiceProvider(catalog) {
|
|
6340
|
+
if (!catalog || !catalog.providers || !catalog.providers.length) return null
|
|
6341
|
+
var id = catalog.selection && catalog.selection.providerId
|
|
6342
|
+
for (var i = 0; i < catalog.providers.length; i += 1) {
|
|
6343
|
+
if (catalog.providers[i].id === id) return catalog.providers[i]
|
|
6344
|
+
}
|
|
6345
|
+
return catalog.providers[0]
|
|
6346
|
+
}
|
|
6347
|
+
|
|
6348
|
+
/** 目录(有哪些识别器、能不能录、能录多久):短暂缓存,别每点一次都问一遍 host。 */
|
|
6349
|
+
function fetchSpeechCatalog(force) {
|
|
6350
|
+
var now = Date.now()
|
|
6351
|
+
if (!force && voice.catalog && now - voice.catalogAt < VOICE_CATALOG_TTL) {
|
|
6352
|
+
return Promise.resolve(voice.catalog)
|
|
6353
|
+
}
|
|
6354
|
+
return fetch(SPEECH_API)
|
|
6355
|
+
.then(function (response) {
|
|
6356
|
+
return response.json()
|
|
6357
|
+
})
|
|
6358
|
+
.then(function (data) {
|
|
6359
|
+
voice.catalog = data
|
|
6360
|
+
voice.catalogAt = Date.now()
|
|
6361
|
+
return data
|
|
6362
|
+
})
|
|
6363
|
+
}
|
|
6364
|
+
|
|
6365
|
+
/**
|
|
6366
|
+
* 波形推进一帧(算法照抄主会话的 Waveform):
|
|
6367
|
+
* 新的电平从右边挤进来,整排往左推;静音时每条线只有 2px 高 —— 就是那条虚线。
|
|
6368
|
+
* @param level 本帧电平(0 = 不采样,只把尺寸摆正)
|
|
6369
|
+
*/
|
|
6370
|
+
function repaintWave(level) {
|
|
6371
|
+
var next = level
|
|
6372
|
+
for (var i = 0; i < waveBars.length; i += 1) {
|
|
6373
|
+
var bar = waveBars[i]
|
|
6374
|
+
var previous = bar.level
|
|
6375
|
+
bar.level = next
|
|
6376
|
+
next = previous
|
|
6377
|
+
var height = 1 + Math.min(1, bar.level * 5) * 17
|
|
6378
|
+
bar.node.setAttribute('y1', String(20 - height))
|
|
6379
|
+
bar.node.setAttribute('y2', String(20 + height))
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
|
|
6383
|
+
/** 把波形清回静音基线(新一次录音从头开始画;主会话是重建 SVG,效果一样)。 */
|
|
6384
|
+
function resetWave() {
|
|
6385
|
+
for (var i = 0; i < waveBars.length; i += 1) {
|
|
6386
|
+
waveBars[i].level = 0
|
|
6387
|
+
waveBars[i].node.setAttribute('y1', '19')
|
|
6388
|
+
waveBars[i].node.setAttribute('y2', '21')
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
|
|
6392
|
+
function stopWaveLoop() {
|
|
6393
|
+
if (voice.waveFrame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(voice.waveFrame)
|
|
6394
|
+
voice.waveFrame = 0
|
|
6395
|
+
voice.waveAt = 0
|
|
6396
|
+
}
|
|
6397
|
+
|
|
6398
|
+
/** 录音时的波形循环:50ms 采一次音量(和主会话同一个节拍)。 */
|
|
6399
|
+
function startWaveLoop() {
|
|
6400
|
+
stopWaveLoop()
|
|
6401
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
6402
|
+
var draw = function (now) {
|
|
6403
|
+
if (voice.phase !== 'recording') return
|
|
6404
|
+
// rAF 会给时间戳,但不是所有环境都给(桩、老实现)——缺了就用当前时间,别让波形卡住
|
|
6405
|
+
var at = typeof now === 'number' ? now : Date.now()
|
|
6406
|
+
if (at - voice.waveAt >= 50) {
|
|
6407
|
+
voice.waveAt = at
|
|
6408
|
+
var level = 0
|
|
6409
|
+
try {
|
|
6410
|
+
level = voice.capture ? voice.capture.level() : 0
|
|
6411
|
+
} catch (error) {
|
|
6412
|
+
level = 0
|
|
6413
|
+
}
|
|
6414
|
+
voice.level = level
|
|
6415
|
+
repaintWave(level)
|
|
6416
|
+
}
|
|
6417
|
+
voice.waveFrame = requestAnimationFrame(draw)
|
|
4485
6418
|
}
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
})
|
|
6419
|
+
voice.waveFrame = requestAnimationFrame(draw)
|
|
6420
|
+
}
|
|
4489
6421
|
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
tools: state.tools.length,
|
|
4536
|
-
toolBusy: state.toolBusy,
|
|
4537
|
-
toolDigest: state.toolDigest,
|
|
4538
|
-
/** 自检用:有没有在飞的请求 / 是不是"收起小窗中止"的那一轮。 */
|
|
4539
|
-
thought: state.thought,
|
|
4540
|
-
status: state.lastStatus,
|
|
4541
|
-
follow: state.follow,
|
|
4542
|
-
hasRequest: !!state.request,
|
|
4543
|
-
aborted: !!state.aborted,
|
|
4544
|
-
asking: !!state.asking,
|
|
6422
|
+
function stopVoiceTicker() {
|
|
6423
|
+
if (voice.ticker) clearInterval(voice.ticker)
|
|
6424
|
+
voice.ticker = 0
|
|
6425
|
+
voice.lastClock = -1
|
|
6426
|
+
stopWaveLoop()
|
|
6427
|
+
}
|
|
6428
|
+
|
|
6429
|
+
/**
|
|
6430
|
+
* 一次录音的全部资源:麦克风流、MediaRecorder、AudioContext、分析器。
|
|
6431
|
+
*
|
|
6432
|
+
* 这几个东西**必须一起释放**:只停 MediaRecorder 的话,标签页上会一直挂着
|
|
6433
|
+
* "正在使用麦克风"的标识(用户会以为在偷听),AudioContext 也会一直占着音频线程。
|
|
6434
|
+
*/
|
|
6435
|
+
function createCapture(onError) {
|
|
6436
|
+
var stream = null
|
|
6437
|
+
var recorder = null
|
|
6438
|
+
var context = null
|
|
6439
|
+
var analyser = null
|
|
6440
|
+
var samples = new Float32Array(256)
|
|
6441
|
+
var chunks = []
|
|
6442
|
+
var lifetime = new AbortController()
|
|
6443
|
+
var disposal = null
|
|
6444
|
+
// ── 采样水龙头(实时字幕用)──
|
|
6445
|
+
var tap = null
|
|
6446
|
+
var tapGain = null
|
|
6447
|
+
/** 16k 采样的分片表(按时序),tapLength 是总采样数。 */
|
|
6448
|
+
var tapChunks = []
|
|
6449
|
+
var tapLength = 0
|
|
6450
|
+
/** 线性重采样的分数位置:跨回调连续,接缝处才不会每 93ms 丢一个点。 */
|
|
6451
|
+
var tapPos = 0
|
|
6452
|
+
/** 最近一次"有声"的采样位置与墙钟时间(停顿定稿靠它切边界)。 */
|
|
6453
|
+
var tapLoudIndex = -1
|
|
6454
|
+
var tapLoudAt = 0
|
|
6455
|
+
|
|
6456
|
+
/** 把一块原始采样(context 采样率)重采样到 16k 攒起来,同时判"有声/静音"。 */
|
|
6457
|
+
function acceptChunk(input) {
|
|
6458
|
+
var rate = context && context.sampleRate ? context.sampleRate : 48000
|
|
6459
|
+
var step = rate / 16000
|
|
6460
|
+
var out = []
|
|
6461
|
+
var pos = tapPos
|
|
6462
|
+
while (pos + 1 < input.length) {
|
|
6463
|
+
var index = Math.floor(pos)
|
|
6464
|
+
var frac = pos - index
|
|
6465
|
+
out.push(input[index] * (1 - frac) + input[index + 1] * frac)
|
|
6466
|
+
pos += step
|
|
4545
6467
|
}
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
6468
|
+
tapPos = pos - input.length
|
|
6469
|
+
if (out.length) {
|
|
6470
|
+
var piece = Float32Array.from(out)
|
|
6471
|
+
tapChunks.push(piece)
|
|
6472
|
+
tapLength += piece.length
|
|
6473
|
+
}
|
|
6474
|
+
var sum = 0
|
|
6475
|
+
for (var i = 0; i < input.length; i += 1) sum += input[i] * input[i]
|
|
6476
|
+
if (Math.sqrt(sum / input.length) >= VOICE_SILENCE_RMS) {
|
|
6477
|
+
tapLoudIndex = tapLength
|
|
6478
|
+
tapLoudAt = Date.now()
|
|
6479
|
+
}
|
|
6480
|
+
}
|
|
6481
|
+
|
|
6482
|
+
/** 取 [from, to) 的 16k 采样(越界自动夹住)。 */
|
|
6483
|
+
function readSamples(from, to) {
|
|
6484
|
+
var a = Math.max(0, Math.floor(from))
|
|
6485
|
+
var b = Math.min(tapLength, Math.ceil(to))
|
|
6486
|
+
if (b <= a) return new Float32Array(0)
|
|
6487
|
+
var out = new Float32Array(b - a)
|
|
6488
|
+
var cursor = 0
|
|
6489
|
+
for (var i = 0; i < tapChunks.length; i += 1) {
|
|
6490
|
+
var piece = tapChunks[i]
|
|
6491
|
+
var end = cursor + piece.length
|
|
6492
|
+
if (end > a && cursor < b) {
|
|
6493
|
+
var s = Math.max(0, a - cursor)
|
|
6494
|
+
var e = Math.min(piece.length, b - cursor)
|
|
6495
|
+
out.set(piece.subarray(s, e), cursor + s - a)
|
|
6496
|
+
}
|
|
6497
|
+
cursor = end
|
|
6498
|
+
if (cursor >= b) break
|
|
6499
|
+
}
|
|
6500
|
+
return out
|
|
6501
|
+
}
|
|
6502
|
+
|
|
6503
|
+
function release() {
|
|
6504
|
+
try {
|
|
6505
|
+
lifetime.abort()
|
|
6506
|
+
} catch (error) {
|
|
6507
|
+
/* noop */
|
|
6508
|
+
}
|
|
6509
|
+
if (tap) {
|
|
6510
|
+
try {
|
|
6511
|
+
tap.onaudioprocess = null
|
|
6512
|
+
tap.disconnect()
|
|
6513
|
+
} catch (error) {
|
|
6514
|
+
/* noop */
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
if (tapGain) {
|
|
6518
|
+
try {
|
|
6519
|
+
tapGain.disconnect()
|
|
6520
|
+
} catch (error) {
|
|
6521
|
+
/* noop */
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
try {
|
|
6525
|
+
if (recorder && recorder.state === 'recording') recorder.stop()
|
|
6526
|
+
} catch (error) {
|
|
6527
|
+
/* noop */
|
|
6528
|
+
}
|
|
6529
|
+
if (stream) {
|
|
6530
|
+
var tracks = stream.getTracks ? stream.getTracks() : []
|
|
6531
|
+
for (var i = 0; i < tracks.length; i += 1) {
|
|
6532
|
+
try {
|
|
6533
|
+
tracks[i].stop()
|
|
6534
|
+
} catch (error) {
|
|
6535
|
+
/* noop */
|
|
6536
|
+
}
|
|
6537
|
+
}
|
|
6538
|
+
}
|
|
6539
|
+
if (context && typeof context.close === 'function') {
|
|
6540
|
+
try {
|
|
6541
|
+
context.close()
|
|
6542
|
+
} catch (error) {
|
|
6543
|
+
/* noop */
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
6546
|
+
return Promise.resolve()
|
|
6547
|
+
}
|
|
6548
|
+
|
|
6549
|
+
function dispose() {
|
|
6550
|
+
if (!disposal) disposal = release()
|
|
6551
|
+
return disposal
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
/** decodeAudioData:新浏览器返回 Promise,老 Safari 只认回调 —— 两种都接住。 */
|
|
6555
|
+
function decode(buffer) {
|
|
6556
|
+
return new Promise(function (resolve, reject) {
|
|
6557
|
+
var pending = context.decodeAudioData(buffer, resolve, reject)
|
|
6558
|
+
if (pending && typeof pending.then === 'function') pending.then(resolve, reject)
|
|
6559
|
+
})
|
|
6560
|
+
}
|
|
6561
|
+
|
|
6562
|
+
return {
|
|
6563
|
+
/** 拿麦克风并开始录(权限弹窗可能很久,晚到的授权要能作废)。 */
|
|
6564
|
+
start: function () {
|
|
6565
|
+
var devices = navigator.mediaDevices
|
|
6566
|
+
if (!devices || typeof devices.getUserMedia !== 'function' || typeof window.MediaRecorder === 'undefined') {
|
|
6567
|
+
return Promise.reject(voiceError('unavailable'))
|
|
6568
|
+
}
|
|
6569
|
+
return devices
|
|
6570
|
+
.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })
|
|
6571
|
+
.then(
|
|
6572
|
+
function (got) {
|
|
6573
|
+
stream = got
|
|
6574
|
+
if (lifetime.signal.aborted) {
|
|
6575
|
+
var granted = got.getTracks ? got.getTracks() : []
|
|
6576
|
+
for (var i = 0; i < granted.length; i += 1) granted[i].stop()
|
|
6577
|
+
throw voiceError('cancelled')
|
|
6578
|
+
}
|
|
6579
|
+
var Ctx = window.AudioContext || window.webkitAudioContext
|
|
6580
|
+
try {
|
|
6581
|
+
context = new Ctx()
|
|
6582
|
+
// 没有用户手势时浏览器会把 AudioContext 挂起:波形要动就得唤醒它
|
|
6583
|
+
if (context.state === 'suspended' && typeof context.resume === 'function') {
|
|
6584
|
+
var resumed = context.resume()
|
|
6585
|
+
if (resumed && typeof resumed.catch === 'function') resumed.catch(function () {})
|
|
6586
|
+
}
|
|
6587
|
+
analyser = context.createAnalyser()
|
|
6588
|
+
analyser.fftSize = samples.length
|
|
6589
|
+
var source = context.createMediaStreamSource(stream)
|
|
6590
|
+
source.connect(analyser)
|
|
6591
|
+
// 原始采样水龙头(只服务于实时字幕):
|
|
6592
|
+
// MediaRecorder 那条路不变,最终那份规范 WAV 还是它出的;这里多接一路,
|
|
6593
|
+
// 直接把麦克风的采样按 16k 攒起来 —— 切窗口时不用每个 tick 去 decode 整个 blob。
|
|
6594
|
+
// ScriptProcessorNode 已废弃但仍在(实测 Chrome 153 可用);没有就关掉实时层。
|
|
6595
|
+
try {
|
|
6596
|
+
if (typeof context.createScriptProcessor === 'function') {
|
|
6597
|
+
tap = context.createScriptProcessor(4096, 1, 1)
|
|
6598
|
+
tapGain = context.createGain()
|
|
6599
|
+
tapGain.gain.value = 0 // 静音:别把麦克风回灌到扬声器(会啸叫)
|
|
6600
|
+
tap.onaudioprocess = function (event) {
|
|
6601
|
+
var input = event && event.inputBuffer ? event.inputBuffer.getChannelData(0) : null
|
|
6602
|
+
if (input) acceptChunk(input)
|
|
6603
|
+
}
|
|
6604
|
+
source.connect(tap)
|
|
6605
|
+
tap.connect(tapGain)
|
|
6606
|
+
tapGain.connect(context.destination)
|
|
6607
|
+
}
|
|
6608
|
+
} catch (error) {
|
|
6609
|
+
tap = null
|
|
6610
|
+
}
|
|
6611
|
+
recorder = new window.MediaRecorder(stream)
|
|
6612
|
+
recorder.ondataavailable = function (event) {
|
|
6613
|
+
if (!lifetime.signal.aborted && event.data && event.data.size > 0) chunks.push(event.data)
|
|
6614
|
+
}
|
|
6615
|
+
recorder.onerror = function () {
|
|
6616
|
+
if (lifetime.signal.aborted) return
|
|
6617
|
+
dispose()
|
|
6618
|
+
if (onError) onError(voiceError('interrupted'))
|
|
6619
|
+
}
|
|
6620
|
+
recorder.start()
|
|
6621
|
+
return true
|
|
6622
|
+
} catch (error) {
|
|
6623
|
+
return dispose().then(function () {
|
|
6624
|
+
throw error
|
|
6625
|
+
})
|
|
6626
|
+
}
|
|
6627
|
+
},
|
|
6628
|
+
function (error) {
|
|
6629
|
+
if (error && error.name === 'NotAllowedError') throw voiceError('permission')
|
|
6630
|
+
if (error && error.name === 'NotFoundError') throw voiceError('missing')
|
|
6631
|
+
throw error
|
|
6632
|
+
},
|
|
6633
|
+
)
|
|
6634
|
+
},
|
|
6635
|
+
|
|
6636
|
+
/** 实时音量(0..1 左右):只用来驱动波形。 */
|
|
6637
|
+
level: function () {
|
|
6638
|
+
if (!analyser) return 0
|
|
6639
|
+
try {
|
|
6640
|
+
analyser.getFloatTimeDomainData(samples)
|
|
6641
|
+
} catch (error) {
|
|
6642
|
+
return 0
|
|
6643
|
+
}
|
|
6644
|
+
var sum = 0
|
|
6645
|
+
for (var i = 0; i < samples.length; i += 1) sum += samples[i] * samples[i]
|
|
6646
|
+
return Math.sqrt(sum / samples.length)
|
|
6647
|
+
},
|
|
6648
|
+
|
|
6649
|
+
/**
|
|
6650
|
+
* 结束录音 → 规范 WAV。
|
|
6651
|
+
* 重采样交给 OfflineAudioContext(浏览器自带的重采样质量比自己写的线性插值好得多),
|
|
6652
|
+
* 采样率固定 16kHz —— host 只收这一种(见 host 的 validateWave)。
|
|
6653
|
+
*/
|
|
6654
|
+
stop: function (maxSeconds) {
|
|
6655
|
+
return new Promise(function (resolve, reject) {
|
|
6656
|
+
if (!recorder || !context || recorder.state !== 'recording') {
|
|
6657
|
+
dispose().then(function () {
|
|
6658
|
+
reject(voiceError('empty'))
|
|
6659
|
+
})
|
|
6660
|
+
return
|
|
6661
|
+
}
|
|
6662
|
+
var settled = false
|
|
6663
|
+
function giveUp(error) {
|
|
6664
|
+
if (settled) return
|
|
6665
|
+
settled = true
|
|
6666
|
+
dispose().then(function () {
|
|
6667
|
+
reject(error)
|
|
6668
|
+
})
|
|
6669
|
+
}
|
|
6670
|
+
recorder.onstop = function () {
|
|
6671
|
+
if (settled) return
|
|
6672
|
+
settled = true
|
|
6673
|
+
var blob
|
|
6674
|
+
try {
|
|
6675
|
+
var tracks = stream && stream.getTracks ? stream.getTracks() : []
|
|
6676
|
+
for (var i = 0; i < tracks.length; i += 1) tracks[i].stop()
|
|
6677
|
+
blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
|
|
6678
|
+
if (!blob.size) throw voiceError('empty')
|
|
6679
|
+
} catch (error) {
|
|
6680
|
+
dispose().then(function () {
|
|
6681
|
+
reject(error)
|
|
6682
|
+
})
|
|
6683
|
+
return
|
|
6684
|
+
}
|
|
6685
|
+
blob
|
|
6686
|
+
.arrayBuffer()
|
|
6687
|
+
.then(function (buffer) {
|
|
6688
|
+
return decode(buffer)
|
|
6689
|
+
})
|
|
6690
|
+
.then(function (decoded) {
|
|
6691
|
+
if (lifetime.signal.aborted) throw voiceError('cancelled')
|
|
6692
|
+
var rate = 16000
|
|
6693
|
+
var seconds = Math.min(decoded.duration || 0, maxSeconds)
|
|
6694
|
+
var frames = Math.max(1, Math.floor(seconds * rate))
|
|
6695
|
+
var Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext
|
|
6696
|
+
var offline = new Offline(1, frames, rate)
|
|
6697
|
+
var source = offline.createBufferSource()
|
|
6698
|
+
source.buffer = decoded
|
|
6699
|
+
source.connect(offline.destination)
|
|
6700
|
+
source.start()
|
|
6701
|
+
return offline.startRendering()
|
|
6702
|
+
})
|
|
6703
|
+
.then(function (rendered) {
|
|
6704
|
+
return encodeWave(rendered.getChannelData(0))
|
|
6705
|
+
})
|
|
6706
|
+
.then(resolve, function (error) {
|
|
6707
|
+
reject(error)
|
|
6708
|
+
})
|
|
6709
|
+
}
|
|
6710
|
+
recorder.onerror = function () {
|
|
6711
|
+
giveUp(voiceError('empty'))
|
|
6712
|
+
}
|
|
6713
|
+
try {
|
|
6714
|
+
recorder.stop()
|
|
6715
|
+
} catch (error) {
|
|
6716
|
+
giveUp(error)
|
|
6717
|
+
}
|
|
6718
|
+
}).then(
|
|
6719
|
+
function (bytes) {
|
|
6720
|
+
return dispose().then(function () {
|
|
6721
|
+
return bytes
|
|
6722
|
+
})
|
|
6723
|
+
},
|
|
6724
|
+
function (error) {
|
|
6725
|
+
return dispose().then(function () {
|
|
6726
|
+
throw error
|
|
6727
|
+
})
|
|
6728
|
+
},
|
|
6729
|
+
)
|
|
6730
|
+
},
|
|
6731
|
+
|
|
6732
|
+
dispose: dispose,
|
|
6733
|
+
/** 实时字幕的水龙头:没接上(浏览器没有 ScriptProcessor)时为 null。 */
|
|
6734
|
+
tap: {
|
|
6735
|
+
supported: function () {
|
|
6736
|
+
return !!tap
|
|
6737
|
+
},
|
|
6738
|
+
length: function () {
|
|
6739
|
+
return tapLength
|
|
6740
|
+
},
|
|
6741
|
+
loudIndex: function () {
|
|
6742
|
+
return tapLoudIndex
|
|
6743
|
+
},
|
|
6744
|
+
loudAt: function () {
|
|
6745
|
+
return tapLoudAt
|
|
6746
|
+
},
|
|
6747
|
+
read: readSamples,
|
|
6748
|
+
/** 自检/单测用:直接喂一块原始采样(真浏览器里由 onaudioprocess 调)。 */
|
|
6749
|
+
accept: acceptChunk,
|
|
6750
|
+
},
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
|
|
6754
|
+
/** 录音中的心跳:到点自动收尾 + 记一次电平(波形另有自己的 50ms 循环)。 */
|
|
6755
|
+
/** 录音开始时把光标交给输入框:预览落字时光标要在末尾跳(textarea 没焦点就不显示光标)。 */
|
|
6756
|
+
function focusAskForRecording() {
|
|
6757
|
+
focusAskBox()
|
|
6758
|
+
}
|
|
6759
|
+
|
|
6760
|
+
function startVoiceTicker() {
|
|
6761
|
+
stopVoiceTicker()
|
|
6762
|
+
voice.ticker = setInterval(function () {
|
|
6763
|
+
if (voice.phase !== 'recording') return
|
|
6764
|
+
var elapsed = Date.now() - voice.startedAt
|
|
6765
|
+
var limit = voice.maxSeconds || VOICE_FALLBACK_SECONDS
|
|
6766
|
+
if (elapsed >= limit * 1000) {
|
|
6767
|
+
finishVoice()
|
|
6768
|
+
return
|
|
6769
|
+
}
|
|
6770
|
+
voice.lastClock = Math.floor(elapsed / 1000)
|
|
6771
|
+
}, 200)
|
|
6772
|
+
startWaveLoop()
|
|
6773
|
+
}
|
|
6774
|
+
|
|
6775
|
+
/** 识别出来的文字 → 输入框(插在光标处,前后补空格,光标落在末尾,接着还能打字)。 */
|
|
6776
|
+
function insertTranscript(text) {
|
|
6777
|
+
var current = String(askBox.value || '')
|
|
6778
|
+
var start = typeof askBox.selectionStart === 'number' && askBox.selectionStart >= 0 ? askBox.selectionStart : current.length
|
|
6779
|
+
var end = typeof askBox.selectionEnd === 'number' && askBox.selectionEnd >= start ? askBox.selectionEnd : start
|
|
6780
|
+
var before = current.slice(0, start)
|
|
6781
|
+
var after = current.slice(end)
|
|
6782
|
+
var prefix = before && !/\s$/.test(before) ? ' ' : ''
|
|
6783
|
+
var suffix = after && !/^\s/.test(after) ? ' ' : ''
|
|
6784
|
+
askBox.value = before + prefix + text + suffix + after
|
|
6785
|
+
var caret = (before + prefix + text).length
|
|
6786
|
+
try {
|
|
6787
|
+
askBox.setSelectionRange(caret, caret)
|
|
6788
|
+
} catch (error) {
|
|
6789
|
+
/* 桩环境没有这个方法 */
|
|
6790
|
+
}
|
|
6791
|
+
try {
|
|
6792
|
+
askBox.focus()
|
|
6793
|
+
} catch (error) {
|
|
6794
|
+
/* noop */
|
|
6795
|
+
}
|
|
6796
|
+
voice.lastText = text
|
|
6797
|
+
refreshAskState()
|
|
6798
|
+
return askBox.value
|
|
6799
|
+
}
|
|
6800
|
+
|
|
6801
|
+
function failVoice(generation, error) {
|
|
6802
|
+
if (generation !== voice.generation) return
|
|
6803
|
+
voice.generation += 1
|
|
6804
|
+
var capture = voice.capture
|
|
6805
|
+
voice.capture = null
|
|
6806
|
+
stopVoiceTicker()
|
|
6807
|
+
if (capture) capture.dispose()
|
|
6808
|
+
// "用户取消"不是错(点了 ✕、收了小窗、插件停用都走这里),不给错误提示
|
|
6809
|
+
var kind = error && error.voiceKind
|
|
6810
|
+
if (kind === 'cancelled') {
|
|
6811
|
+
resetVoiceRow()
|
|
6812
|
+
return
|
|
6813
|
+
}
|
|
6814
|
+
// 没听清 / 中断 / 失败:留在 feedback 里给原因 + 🎤 重录(和主会话一致)
|
|
6815
|
+
voiceFeedback(voiceMessageOf(error), kind === 'empty' || kind === 'interrupted' ? 'warn' : 'error', 'retry')
|
|
6816
|
+
}
|
|
6817
|
+
|
|
6818
|
+
/** 回到 idle:工具行原样还回去。 */
|
|
6819
|
+
function resetVoiceRow() {
|
|
6820
|
+
stopVoiceTicker()
|
|
6821
|
+
stopLive()
|
|
6822
|
+
if (voice.idleTimer) clearTimeout(voice.idleTimer)
|
|
6823
|
+
voice.idleTimer = 0
|
|
6824
|
+
voice.phase = 'idle'
|
|
6825
|
+
voice.action = null
|
|
6826
|
+
while (voiceActionSlot.firstChild) voiceActionSlot.removeChild(voiceActionSlot.firstChild)
|
|
6827
|
+
setVoiceActivity('', {})
|
|
6828
|
+
paintVoice()
|
|
6829
|
+
}
|
|
6830
|
+
|
|
6831
|
+
// ── 实时字幕(半句预览 + 停顿定稿)──────────────────────────────────────
|
|
6832
|
+
//
|
|
6833
|
+
// 说话时字就往输入框里长:每 ~1.3s 把"还没定稿的这半句"送去识别一次,整段替换上一拍
|
|
6834
|
+
// 的预览;检测到 ~0.6s 静音(这句说完了)就把这句送去识别并**定稿**,之后不再变。
|
|
6835
|
+
// 停止(■)时仍然走原来那条"整段规范 WAV"的路,回来的结果给这一整段收口:
|
|
6836
|
+
// · 整段识别**以已定稿的文字开头**(正常情况)→ 只补后半句,定稿的字一个不动;
|
|
6837
|
+
// · 对不上(整段上下文让引擎改了前面)→ 以整段为准整块替换,并在自检里记一笔。
|
|
6838
|
+
// ✕ 取消 = 把这次插进去的文字整块撤掉(对应主会话那个 aria-label「丢弃识别文字」)。
|
|
6839
|
+
//
|
|
6840
|
+
// 为什么预览用**采样水龙头**而不是 MediaRecorder 的 blob:切窗口只要一段连续采样,
|
|
6841
|
+
// 而每个 tick 去 decode 整个 blob 是 O(总时长) 的白活(说到 60 秒就是每秒 decode 60 秒音频)。
|
|
6842
|
+
|
|
6843
|
+
/** 定稿与预览拼起来时的连接处(中文直接接,英文补一个空格)。 */
|
|
6844
|
+
function liveJoin(left, right) {
|
|
6845
|
+
if (!left) return right
|
|
6846
|
+
if (!right) return left
|
|
6847
|
+
if (/[\s]$/.test(left) || /^[\s,。;:!?、,.!?;:]/.test(right)) return left + right
|
|
6848
|
+
return left + ' ' + right
|
|
6849
|
+
}
|
|
6850
|
+
|
|
6851
|
+
/** 光标位置(拿不到就当作末尾)。 */
|
|
6852
|
+
function caretIndex() {
|
|
6853
|
+
var value = String(askBox.value || '')
|
|
6854
|
+
return typeof askBox.selectionStart === 'number' && askBox.selectionStart >= 0
|
|
6855
|
+
? Math.min(askBox.selectionStart, value.length)
|
|
6856
|
+
: value.length
|
|
6857
|
+
}
|
|
6858
|
+
|
|
6859
|
+
/** 这一段(分隔空格 + 定稿 + 预览)在输入框里该长什么样。 */
|
|
6860
|
+
function liveCompose() {
|
|
6861
|
+
var live = voice.live
|
|
6862
|
+
var body = liveJoin(live.committed, live.preview)
|
|
6863
|
+
live.text = body ? live.separator + body : ''
|
|
6864
|
+
return live.text
|
|
6865
|
+
}
|
|
6866
|
+
|
|
6867
|
+
/**
|
|
6868
|
+
* 把这一段落到输入框里(原位替换)。
|
|
6869
|
+
* 用户在我们这段**里面**改过字就不抢他的编辑:停掉实时层,以后按"停止后再出字"走。
|
|
6870
|
+
*/
|
|
6871
|
+
function liveWrite() {
|
|
6872
|
+
var live = voice.live
|
|
6873
|
+
var value = String(askBox.value || '')
|
|
6874
|
+
var previous = live.text
|
|
6875
|
+
var start = live.start
|
|
6876
|
+
if (previous) {
|
|
6877
|
+
if (value.indexOf(previous) >= 0) start = value.indexOf(previous)
|
|
6878
|
+
else if (start >= 0 && value.slice(start, start + previous.length) === previous) {
|
|
6879
|
+
/* 位置没变 */
|
|
6880
|
+
} else {
|
|
6881
|
+
live.disabled = true
|
|
6882
|
+
live.reason = 'edited'
|
|
6883
|
+
stopLive()
|
|
6884
|
+
return false
|
|
6885
|
+
}
|
|
6886
|
+
}
|
|
6887
|
+
if (start < 0) start = caretIndex()
|
|
6888
|
+
var end = previous ? start + previous.length : start
|
|
6889
|
+
var caret = typeof askBox.selectionStart === 'number' ? askBox.selectionStart : end
|
|
6890
|
+
// 光标原本就贴在这段文字的末尾(= 用户没在改这段,只是在看)→ 跟着最新文字走,
|
|
6891
|
+
// 这样光标一直在新冒出来的字后面跳;用户把光标挪到别处(或在后面打字)就原位保留。
|
|
6892
|
+
var atBlockEnd = previous ? caret === end : caret === start
|
|
6893
|
+
var next = liveCompose()
|
|
6894
|
+
askBox.value = value.slice(0, start) + next + value.slice(end)
|
|
6895
|
+
live.start = next ? start : -1
|
|
6896
|
+
live.text = next
|
|
6897
|
+
var moved = next.length - previous.length
|
|
6898
|
+
var nextCaret = atBlockEnd ? start + next.length : caret > end ? caret + moved : caret
|
|
6899
|
+
if (nextCaret < 0) nextCaret = 0
|
|
6900
|
+
if (nextCaret > askBox.value.length) nextCaret = askBox.value.length
|
|
6901
|
+
try {
|
|
6902
|
+
askBox.setSelectionRange(nextCaret, nextCaret)
|
|
6903
|
+
} catch (error) {
|
|
6904
|
+
/* 桩环境没有这个方法 */
|
|
6905
|
+
}
|
|
6906
|
+
refreshAskState()
|
|
6907
|
+
return true
|
|
6908
|
+
}
|
|
6909
|
+
|
|
6910
|
+
|
|
6911
|
+
function stopLive() {
|
|
6912
|
+
var live = voice.live
|
|
6913
|
+
live.active = false
|
|
6914
|
+
live.inflight = false
|
|
6915
|
+
if (live.timer) clearInterval(live.timer)
|
|
6916
|
+
live.timer = 0
|
|
6917
|
+
if (live.controller) {
|
|
6918
|
+
try {
|
|
6919
|
+
live.controller.abort()
|
|
6920
|
+
} catch (error) {
|
|
6921
|
+
/* noop */
|
|
6922
|
+
}
|
|
6923
|
+
}
|
|
6924
|
+
live.controller = null
|
|
6925
|
+
}
|
|
6926
|
+
|
|
6927
|
+
/** 开实时层(拿不到采样水龙头就不开:功能退回"说完点 ■ 再出字")。 */
|
|
6928
|
+
function startLive(capture) {
|
|
6929
|
+
var live = voice.live
|
|
6930
|
+
stopLive()
|
|
6931
|
+
live.committed = ''
|
|
6932
|
+
live.preview = ''
|
|
6933
|
+
live.text = ''
|
|
6934
|
+
live.start = -1
|
|
6935
|
+
live.inflight = false
|
|
6936
|
+
live.passes = { preview: 0, commit: 0 }
|
|
6937
|
+
live.rewritten = false
|
|
6938
|
+
live.disabled = false
|
|
6939
|
+
live.reason = ''
|
|
6940
|
+
if (!capture || !capture.tap || !capture.tap.supported()) {
|
|
6941
|
+
live.disabled = true
|
|
6942
|
+
live.reason = 'no-tap'
|
|
6943
|
+
return false
|
|
6944
|
+
}
|
|
6945
|
+
// 云端识别器:每一拍都是一次**付费**调用(1.3 秒一拍 ≈ 46 次/分钟),不做预览。
|
|
6946
|
+
// 本机模型不花钱、只占点 CPU,才是实时字幕该待的地方。
|
|
6947
|
+
var provider = voiceProvider(voice.catalog)
|
|
6948
|
+
if (provider && provider.location === 'cloud') {
|
|
6949
|
+
live.disabled = true
|
|
6950
|
+
live.reason = 'cloud'
|
|
6951
|
+
return false
|
|
6952
|
+
}
|
|
6953
|
+
// 我们这段文字从**当前光标处**开始长;前面不是空白就补一个分隔空格
|
|
6954
|
+
var value = String(askBox.value || '')
|
|
6955
|
+
var caret = caretIndex()
|
|
6956
|
+
live.separator = caret > 0 && !/\s$/.test(value.slice(0, caret)) ? ' ' : ''
|
|
6957
|
+
live.start = -1
|
|
6958
|
+
live.boundary = capture.tap.length()
|
|
6959
|
+
live.phraseStart = live.boundary
|
|
6960
|
+
live.startedAt = Date.now()
|
|
6961
|
+
live.tickAt = 0
|
|
6962
|
+
live.active = true
|
|
6963
|
+
live.timer = setInterval(liveTick, 200)
|
|
6964
|
+
return true
|
|
6965
|
+
}
|
|
6966
|
+
|
|
6967
|
+
/**
|
|
6968
|
+
* 送一段(16k 采样,[from,to))去识别。
|
|
6969
|
+
* **"没听清"不是故障**:停顿那几秒里窗口是纯静音,识别本来就该返回空
|
|
6970
|
+
*(曾经把空结果当失败计数,连挂 3 次就把整个实时层关了 —— 表现是"只跟第一句,后面不吐字")。
|
|
6971
|
+
* @returns 识别到的文字(可能是 '')
|
|
6972
|
+
*/
|
|
6973
|
+
function liveSend(from, to) {
|
|
6974
|
+
var live = voice.live
|
|
6975
|
+
var capture = voice.capture
|
|
6976
|
+
if (!capture || !capture.tap) return Promise.reject(new Error('没有采样水龙头'))
|
|
6977
|
+
var samples = capture.tap.read(from, to)
|
|
6978
|
+
if (samples.length < 1600) return Promise.reject(new Error('这段太短了'))
|
|
6979
|
+
var controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
6980
|
+
live.controller = controller
|
|
6981
|
+
return fetch(SPEECH_TRANSCRIBE, {
|
|
6982
|
+
method: 'POST',
|
|
6983
|
+
headers: { 'content-type': 'application/json' },
|
|
6984
|
+
body: JSON.stringify({ audioBase64: bytesToBase64(encodeWave(samples)), partial: true }),
|
|
6985
|
+
signal: controller ? controller.signal : undefined,
|
|
6986
|
+
})
|
|
6987
|
+
.then(function (response) {
|
|
6988
|
+
return response.json()
|
|
6989
|
+
})
|
|
6990
|
+
.then(function (data) {
|
|
6991
|
+
if (data && data.ok === true) return String(data.text || '').trim()
|
|
6992
|
+
if (data && data.code === 'empty-transcript') return ''
|
|
6993
|
+
throw new Error(String((data && data.error) || '识别失败'))
|
|
6994
|
+
})
|
|
6995
|
+
}
|
|
6996
|
+
|
|
6997
|
+
/** 一句话说完了(静音够久):把它送去识别并定稿。 */
|
|
6998
|
+
function liveCommit(boundary) {
|
|
6999
|
+
var live = voice.live
|
|
7000
|
+
var from = live.phraseStart
|
|
7001
|
+
var to = Math.min(boundary, from + 16000 * VOICE_COMMIT_MAX_SECONDS)
|
|
7002
|
+
if (to - from < 1600) return
|
|
7003
|
+
live.inflight = true
|
|
7004
|
+
live.passes.commit += 1
|
|
7005
|
+
liveSend(from, to)
|
|
7006
|
+
.then(function (text) {
|
|
7007
|
+
if (!live.active) return
|
|
7008
|
+
live.previewFails = 0
|
|
7009
|
+
if (text) {
|
|
7010
|
+
live.committed = liveJoin(live.committed, text)
|
|
7011
|
+
live.preview = ''
|
|
7012
|
+
liveWrite()
|
|
7013
|
+
}
|
|
7014
|
+
live.boundary = to
|
|
7015
|
+
live.phraseStart = to
|
|
7016
|
+
})
|
|
7017
|
+
.catch(function () {
|
|
7018
|
+
/* 这一句没转出来:不打断录音,等停止时整段再试一次 */
|
|
7019
|
+
})
|
|
7020
|
+
.then(function () {
|
|
7021
|
+
live.inflight = false
|
|
7022
|
+
live.controller = null
|
|
7023
|
+
})
|
|
7024
|
+
}
|
|
7025
|
+
|
|
7026
|
+
/**
|
|
7027
|
+
* 半句预览:把"最后 10 秒还没定稿的那半句"送去识别,整段替换上一拍的预览。
|
|
7028
|
+
* @returns 这一拍真的发出去了没有(没发就不该吃掉节流窗口 —— 否则录到 0.2 秒时那次空转
|
|
7029
|
+
* 会把第一拍预览推到 1.5 秒之后,用户会觉得"怎么半天不出字")。
|
|
7030
|
+
*/
|
|
7031
|
+
function livePreview() {
|
|
7032
|
+
var live = voice.live
|
|
7033
|
+
var capture = voice.capture
|
|
7034
|
+
if (!capture || !capture.tap) return false
|
|
7035
|
+
// 上次定稿之后还没开口(一直是静音)→ 不浪费调用,也别把上一拍预览抹掉
|
|
7036
|
+
var loudIndex = capture.tap.loudIndex()
|
|
7037
|
+
if (loudIndex < 0 || loudIndex <= live.phraseStart) return false
|
|
7038
|
+
var length = capture.tap.length()
|
|
7039
|
+
var from = Math.max(live.phraseStart, length - 16000 * VOICE_PREVIEW_WINDOW)
|
|
7040
|
+
if (length - from < 16000 * 0.5) return false
|
|
7041
|
+
live.inflight = true
|
|
7042
|
+
live.passes.preview += 1
|
|
7043
|
+
liveSend(from, length)
|
|
7044
|
+
.then(function (text) {
|
|
7045
|
+
if (!live.active) return
|
|
7046
|
+
live.previewFails = 0
|
|
7047
|
+
if (text === live.preview) return
|
|
7048
|
+
// 这一拍没识别到内容(窗口里正好都是静音):留着上一拍的预览,别闪没
|
|
7049
|
+
if (!text) return
|
|
7050
|
+
live.preview = text
|
|
7051
|
+
liveWrite()
|
|
7052
|
+
})
|
|
7053
|
+
.catch(function (error) {
|
|
7054
|
+
if (!live.active) return
|
|
7055
|
+
// 预览连续失败就不再打扰用户(停止时那条路仍然会出字);
|
|
7056
|
+
// 阈值给宽一点:偶发一次超时不该把实时字幕整个关掉
|
|
7057
|
+
if (error && error.name === 'AbortError') return
|
|
7058
|
+
live.previewFails = (live.previewFails || 0) + 1
|
|
7059
|
+
if (live.previewFails >= 5) {
|
|
7060
|
+
live.disabled = true
|
|
7061
|
+
live.reason = 'preview-failed'
|
|
7062
|
+
stopLive()
|
|
7063
|
+
}
|
|
7064
|
+
})
|
|
7065
|
+
.then(function () {
|
|
7066
|
+
live.inflight = false
|
|
7067
|
+
live.controller = null
|
|
7068
|
+
})
|
|
7069
|
+
return true
|
|
7070
|
+
}
|
|
7071
|
+
|
|
7072
|
+
/** 实时层的节拍:先看有没有"一句话说完了",再看要不要刷半句预览。 */
|
|
7073
|
+
function liveTick() {
|
|
7074
|
+
var live = voice.live
|
|
7075
|
+
if (!live.active || live.disabled || voice.phase !== 'recording') return
|
|
7076
|
+
var capture = voice.capture
|
|
7077
|
+
if (!capture || !capture.tap || !capture.tap.supported()) return
|
|
7078
|
+
if (live.inflight) return
|
|
7079
|
+
var now = Date.now()
|
|
7080
|
+
var length = capture.tap.length()
|
|
7081
|
+
var loudIndex = capture.tap.loudIndex()
|
|
7082
|
+
var loudAt = capture.tap.loudAt()
|
|
7083
|
+
// ① 停顿定稿:最近一次有声已经过去 600ms 以上,且这句够长
|
|
7084
|
+
if (loudIndex >= 0 && now - loudAt >= VOICE_PAUSE_MS && now - loudAt < 5000) {
|
|
7085
|
+
var boundary = Math.min(length, loudIndex + Math.round(16000 * 0.25))
|
|
7086
|
+
if (boundary - live.phraseStart >= (16000 * VOICE_PAUSE_MIN_MS) / 1000) {
|
|
7087
|
+
live.pendingReady = true
|
|
7088
|
+
liveCommit(boundary)
|
|
7089
|
+
return
|
|
7090
|
+
}
|
|
7091
|
+
}
|
|
7092
|
+
// ② 半句预览:每 1.3 秒刷一次(只有真发出去才算用掉这一拍)
|
|
7093
|
+
if (now - live.tickAt < VOICE_PREVIEW_EVERY) return
|
|
7094
|
+
if (livePreview()) live.tickAt = now
|
|
7095
|
+
}
|
|
7096
|
+
|
|
7097
|
+
/**
|
|
7098
|
+
* ■ 停止后的收口:整段识别已经拿到 fullText,把它和已经插进去的那段对齐。
|
|
7099
|
+
* 正常情况(整段以定稿开头)只补后半句;对不上就整块替换(以整段为准)。
|
|
7100
|
+
*/
|
|
7101
|
+
function liveFinalize(fullText) {
|
|
7102
|
+
var live = voice.live
|
|
7103
|
+
var committed = live.committed
|
|
7104
|
+
var text = String(fullText || '').trim()
|
|
7105
|
+
stopLive()
|
|
7106
|
+
if (!text) {
|
|
7107
|
+
// 整段一个都没识别出来:已经定稿的部分留着(用户看得见),预览丢掉
|
|
7108
|
+
live.preview = ''
|
|
7109
|
+
liveWrite()
|
|
7110
|
+
return live.committed
|
|
7111
|
+
}
|
|
7112
|
+
var merged = text
|
|
7113
|
+
if (committed) {
|
|
7114
|
+
var rest = liveTailAfterCommitted(committed, text)
|
|
7115
|
+
if (rest === null) {
|
|
7116
|
+
live.rewritten = true
|
|
7117
|
+
merged = text
|
|
7118
|
+
} else {
|
|
7119
|
+
merged = liveJoin(committed, rest)
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
live.committed = merged
|
|
7123
|
+
live.preview = ''
|
|
7124
|
+
liveWrite()
|
|
7125
|
+
return merged
|
|
7126
|
+
}
|
|
7127
|
+
|
|
7128
|
+
/**
|
|
7129
|
+
* 整段识别里"已定稿那部分"之后还剩什么。
|
|
7130
|
+
* 归一化(去空白与标点)之后比前缀:对得上 → 返回剩余原文;对不上 → null。
|
|
7131
|
+
*/
|
|
7132
|
+
function liveTailAfterCommitted(committed, full) {
|
|
7133
|
+
var norm = function (text) {
|
|
7134
|
+
var out = []
|
|
7135
|
+
for (var i = 0; i < text.length; i += 1) {
|
|
7136
|
+
var ch = text[i]
|
|
7137
|
+
if (/[\s,。;:!?、,.!?;:'"()()\[\]【】…—-]/.test(ch)) continue
|
|
7138
|
+
out.push({ ch: ch, at: i })
|
|
7139
|
+
}
|
|
7140
|
+
return out
|
|
7141
|
+
}
|
|
7142
|
+
var want = norm(committed)
|
|
7143
|
+
var got = norm(full)
|
|
7144
|
+
if (!want.length || got.length < want.length) return null
|
|
7145
|
+
for (var i = 0; i < want.length; i += 1) {
|
|
7146
|
+
if (want[i].ch !== got[i].ch) return null
|
|
7147
|
+
}
|
|
7148
|
+
return full.slice(got[want.length - 1].at + 1).trim()
|
|
7149
|
+
}
|
|
7150
|
+
|
|
7151
|
+
/** 开始一段录音(先问 host 要目录:能不能录、最长多久)。 */
|
|
7152
|
+
function startVoice() {
|
|
7153
|
+
if (!recordingSupported()) {
|
|
7154
|
+
voiceFeedback(VOICE_ERRORS.unavailable, 'error', null)
|
|
7155
|
+
return
|
|
7156
|
+
}
|
|
7157
|
+
var generation = ++voice.generation
|
|
7158
|
+
voice.phase = 'requesting'
|
|
7159
|
+
setVoiceActivity('请允许使用麦克风…', { dot: true })
|
|
7160
|
+
setVoiceAction(null)
|
|
7161
|
+
paintVoice()
|
|
7162
|
+
fetchSpeechCatalog(true)
|
|
7163
|
+
.then(function (catalog) {
|
|
7164
|
+
if (generation !== voice.generation) return null
|
|
7165
|
+
if (!catalog || catalog.available !== true) {
|
|
7166
|
+
var provider = voiceProvider(catalog)
|
|
7167
|
+
var reason = catalog ? catalog.reason : ''
|
|
7168
|
+
var text =
|
|
7169
|
+
reason === 'unprepared' || (provider && provider.phase !== 'ready' && provider.phase !== 'standby')
|
|
7170
|
+
? '语音模型还没准备好' + (provider && provider.name ? '(' + provider.name + ')' : '')
|
|
7171
|
+
: catalog && catalog.error
|
|
7172
|
+
? catalog.error
|
|
7173
|
+
: '这个部署没有可用的语音识别服务'
|
|
7174
|
+
// 有救的(模型没装)给一个「准备模型」按钮:下载在 host 上跑,几百 MB 到 1G,得用户自己点
|
|
7175
|
+
voiceFeedback(text, 'warn', provider ? 'prepare' : 'retry')
|
|
7176
|
+
return null
|
|
7177
|
+
}
|
|
7178
|
+
var limits = catalog.limits || {}
|
|
7179
|
+
voice.maxSeconds = clamp(Number(limits.maxSeconds) || VOICE_FALLBACK_SECONDS, 5, 600)
|
|
7180
|
+
var capture = createCapture(function (error) {
|
|
7181
|
+
failVoice(generation, error)
|
|
7182
|
+
})
|
|
7183
|
+
voice.capture = capture
|
|
7184
|
+
return capture.start().then(function () {
|
|
7185
|
+
if (generation !== voice.generation) {
|
|
7186
|
+
capture.dispose()
|
|
7187
|
+
return null
|
|
7188
|
+
}
|
|
7189
|
+
voice.phase = 'recording'
|
|
7190
|
+
voice.startedAt = Date.now()
|
|
7191
|
+
resetWave()
|
|
7192
|
+
setVoiceActivity('', {})
|
|
7193
|
+
paintVoice()
|
|
7194
|
+
// 光标交给输入框:实时字幕落字时要看见光标在末尾跳(textarea 没焦点就不画光标)
|
|
7195
|
+
focusAskForRecording()
|
|
7196
|
+
startVoiceTicker()
|
|
7197
|
+
// 实时字幕:说话时字就往输入框里长(拿不到采样水龙头就自动退回"停止后出字")
|
|
7198
|
+
startLive(capture)
|
|
7199
|
+
return true
|
|
7200
|
+
})
|
|
7201
|
+
})
|
|
7202
|
+
.catch(function (error) {
|
|
7203
|
+
failVoice(generation, error)
|
|
7204
|
+
})
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
/** 结束录音 → 送去识别 → 文字插进输入框。 */
|
|
7208
|
+
function finishVoice() {
|
|
7209
|
+
if (voice.phase !== 'recording') return
|
|
7210
|
+
var generation = voice.generation
|
|
7211
|
+
var capture = voice.capture
|
|
7212
|
+
var maxSeconds = voice.maxSeconds || VOICE_FALLBACK_SECONDS
|
|
7213
|
+
stopVoiceTicker()
|
|
7214
|
+
voice.phase = 'transcribing'
|
|
7215
|
+
setVoiceActivity('识别中…', { dot: true })
|
|
7216
|
+
setVoiceAction(null)
|
|
7217
|
+
paintVoice()
|
|
7218
|
+
var abort = typeof AbortController === 'function' ? new AbortController() : null
|
|
7219
|
+
voice.abort = abort
|
|
7220
|
+
capture
|
|
7221
|
+
.stop(maxSeconds)
|
|
7222
|
+
.then(function (bytes) {
|
|
7223
|
+
if (generation !== voice.generation) return null
|
|
7224
|
+
if (!bytes || !bytes.length) throw voiceError('empty')
|
|
7225
|
+
if (bytes.length > VOICE_MAX_BYTES) throw new Error('录音超过服务限制,请缩短录音后重试。')
|
|
7226
|
+
return fetch(SPEECH_TRANSCRIBE, {
|
|
7227
|
+
method: 'POST',
|
|
7228
|
+
headers: { 'content-type': 'application/json' },
|
|
7229
|
+
body: JSON.stringify({ audioBase64: bytesToBase64(bytes) }),
|
|
7230
|
+
signal: abort ? abort.signal : undefined,
|
|
7231
|
+
})
|
|
7232
|
+
})
|
|
7233
|
+
.then(function (response) {
|
|
7234
|
+
if (generation !== voice.generation || !response) return null
|
|
7235
|
+
return response.json().then(function (data) {
|
|
7236
|
+
if (generation !== voice.generation) return null
|
|
7237
|
+
if (!data || data.ok !== true) {
|
|
7238
|
+
var error = String((data && data.error) || '识别失败')
|
|
7239
|
+
if (data && data.code === 'unprepared') {
|
|
7240
|
+
voiceFeedback(error, 'warn', 'prepare')
|
|
7241
|
+
} else if (data && data.code === 'empty-transcript') {
|
|
7242
|
+
voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
|
|
7243
|
+
} else {
|
|
7244
|
+
voiceFeedback('语音识别失败:' + error, 'error', 'retry')
|
|
7245
|
+
}
|
|
7246
|
+
return null
|
|
7247
|
+
}
|
|
7248
|
+
var text = String(data.text || '').trim()
|
|
7249
|
+
var liveHasText = voice.live.active && !!(voice.live.committed || voice.live.preview || voice.live.text)
|
|
7250
|
+
if (!text) {
|
|
7251
|
+
if (liveHasText) {
|
|
7252
|
+
// 整段没转出来,但实时那几段已经落进输入框了:留着他看得见的那份
|
|
7253
|
+
voice.lastText = liveFinalize('')
|
|
7254
|
+
voice.capture = null
|
|
7255
|
+
voice.abort = null
|
|
7256
|
+
resetVoiceRow()
|
|
7257
|
+
return null
|
|
7258
|
+
}
|
|
7259
|
+
voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
|
|
7260
|
+
return null
|
|
7261
|
+
}
|
|
7262
|
+
if (liveHasText) {
|
|
7263
|
+
// 实时字幕已经在输入框里写了字:用它给整段收口(定稿部分尽量不动)
|
|
7264
|
+
voice.lastText = liveFinalize(text)
|
|
7265
|
+
} else {
|
|
7266
|
+
insertTranscript(text)
|
|
7267
|
+
}
|
|
7268
|
+
// 插入成功 = **直接回 idle**:文字就摆在上面那行,不必再报一次
|
|
7269
|
+
//(主会话也是这样:成功不额外说话,录音行收起、🎤 回位)。
|
|
7270
|
+
voice.capture = null
|
|
7271
|
+
voice.abort = null
|
|
7272
|
+
resetVoiceRow()
|
|
7273
|
+
return text
|
|
7274
|
+
})
|
|
7275
|
+
})
|
|
7276
|
+
.catch(function (error) {
|
|
7277
|
+
if (generation !== voice.generation) return
|
|
7278
|
+
// 用户点了 ✕ / 收起小窗:静默(这不是失败,是他自己不要了)
|
|
7279
|
+
if (error && error.name === 'AbortError') return
|
|
7280
|
+
var kind = error && error.voiceKind
|
|
7281
|
+
if (kind === 'cancelled') return
|
|
7282
|
+
if (kind === 'empty') {
|
|
7283
|
+
voiceFeedback(VOICE_ERRORS.empty, 'warn', 'retry')
|
|
7284
|
+
return
|
|
7285
|
+
}
|
|
7286
|
+
voiceFeedback('语音识别失败:' + voiceMessageOf(error), 'error', 'retry')
|
|
7287
|
+
})
|
|
7288
|
+
.then(function () {
|
|
7289
|
+
if (generation !== voice.generation) return
|
|
7290
|
+
voice.capture = null
|
|
7291
|
+
voice.abort = null
|
|
7292
|
+
})
|
|
7293
|
+
}
|
|
7294
|
+
|
|
7295
|
+
/**
|
|
7296
|
+
* 取消当前这一轮(录音或识别)→ 回到 idle。✕ / Esc / 关面板 / 停用插件都走它。
|
|
7297
|
+
* 一定会松开麦克风、并且让晚到的回调全部作废(generation + 1)。
|
|
7298
|
+
*/
|
|
7299
|
+
function cancelVoice(options) {
|
|
7300
|
+
if (!voice) return
|
|
7301
|
+
var opts = typeof options === 'string' ? { note: options } : options || {}
|
|
7302
|
+
var capture = voice.capture
|
|
7303
|
+
voice.generation += 1
|
|
7304
|
+
voice.capture = null
|
|
7305
|
+
if (voice.abort) {
|
|
7306
|
+
try {
|
|
7307
|
+
voice.abort.abort()
|
|
7308
|
+
} catch (error) {
|
|
7309
|
+
/* noop */
|
|
7310
|
+
}
|
|
7311
|
+
}
|
|
7312
|
+
voice.abort = null
|
|
7313
|
+
if (voice.pollTimer) clearTimeout(voice.pollTimer)
|
|
7314
|
+
voice.pollTimer = 0
|
|
7315
|
+
// 取消 = 只停(不再更新那段实时文字),**已经说出来的字一律留着**。
|
|
7316
|
+
// 早先跟着主会话的 ✕ 做过"丢弃识别文字",实际用起来是"我按了取消,字没了" ——
|
|
7317
|
+
// 录到一半反悔时更常见的诉求是"留着我已经说出来的",所以统一成都留。
|
|
7318
|
+
stopLive()
|
|
7319
|
+
if (capture) capture.dispose()
|
|
7320
|
+
resetVoiceRow()
|
|
7321
|
+
// 少数情况下要交代一句(例如"这条消息先发出去了"):留在 feedback 里,2 秒后自己回 idle
|
|
7322
|
+
if (opts.note) {
|
|
7323
|
+
voiceFeedback(opts.note, '', null)
|
|
7324
|
+
voice.idleTimer = setTimeout(function () {
|
|
7325
|
+
if (voice.phase !== 'feedback') return
|
|
7326
|
+
resetVoiceRow()
|
|
7327
|
+
}, 2200)
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7330
|
+
|
|
7331
|
+
/** 让 host 去准备识别模型(首次要下载);下载在 host 上跑,关掉面板也继续。 */
|
|
7332
|
+
function prepareVoice() {
|
|
7333
|
+
voice.phase = 'transcribing' // 复用"进行中"的形态:✕ + 呼吸点 + 文案
|
|
7334
|
+
setVoiceActivity('正在准备语音模型…', { dot: true })
|
|
7335
|
+
setVoiceAction(null)
|
|
7336
|
+
paintVoice()
|
|
7337
|
+
fetch(SPEECH_PREPARE, {
|
|
7338
|
+
method: 'POST',
|
|
7339
|
+
headers: { 'content-type': 'application/json' },
|
|
7340
|
+
body: JSON.stringify({}),
|
|
7341
|
+
})
|
|
7342
|
+
.then(function (response) {
|
|
7343
|
+
return response.json()
|
|
7344
|
+
})
|
|
7345
|
+
.then(function (data) {
|
|
7346
|
+
if (!data || data.ok !== true) {
|
|
7347
|
+
voiceFeedback('准备失败:' + ((data && data.error) || '未知原因'), 'error', 'prepare')
|
|
7348
|
+
return
|
|
7349
|
+
}
|
|
7350
|
+
voice.catalog = data.catalog || null
|
|
7351
|
+
voice.catalogAt = 0 // 状态变了,下一次点击重新问 host
|
|
7352
|
+
pollPrepare(0)
|
|
7353
|
+
})
|
|
7354
|
+
.catch(function (error) {
|
|
7355
|
+
voiceFeedback('准备失败:' + voiceMessageOf(error), 'error', 'prepare')
|
|
7356
|
+
})
|
|
7357
|
+
}
|
|
7358
|
+
|
|
7359
|
+
/** 轮询准备进度(下载在 host 上,这里只是播报)。 */
|
|
7360
|
+
function pollPrepare(round) {
|
|
7361
|
+
if (round > 480) return // 最多看 ~12 分钟,之后交给用户自己再来
|
|
7362
|
+
if (voice.pollTimer) clearTimeout(voice.pollTimer)
|
|
7363
|
+
voice.pollTimer = setTimeout(function () {
|
|
7364
|
+
fetchSpeechCatalog(true)
|
|
7365
|
+
.then(function (catalog) {
|
|
7366
|
+
if (catalog && catalog.available) {
|
|
7367
|
+
voiceFeedback('语音模型已就绪,可以开始说话', '', 'retry')
|
|
7368
|
+
return
|
|
7369
|
+
}
|
|
7370
|
+
var provider = voiceProvider(catalog)
|
|
7371
|
+
if (provider && provider.phase === 'failed') {
|
|
7372
|
+
voiceFeedback('准备失败:' + (provider.message || '模型下载出错'), 'error', 'prepare')
|
|
7373
|
+
return
|
|
7374
|
+
}
|
|
7375
|
+
var total = provider && provider.totalBytes ? provider.totalBytes : 0
|
|
7376
|
+
var done = provider && provider.completedBytes ? provider.completedBytes : 0
|
|
7377
|
+
var progress = total > 0 ? '(' + Math.round((done / total) * 100) + '%)' : ''
|
|
7378
|
+
setVoiceActivity('正在准备语音模型' + progress + '…(可以先去忙别的,下载在后台继续)', { dot: true })
|
|
7379
|
+
voice.phase = 'transcribing'
|
|
7380
|
+
paintVoice()
|
|
7381
|
+
pollPrepare(round + 1)
|
|
7382
|
+
})
|
|
7383
|
+
.catch(function () {
|
|
7384
|
+
pollPrepare(round + 1)
|
|
7385
|
+
})
|
|
7386
|
+
}, 1500)
|
|
7387
|
+
}
|
|
7388
|
+
|
|
7389
|
+
var offMic = listen(micButton, 'click', function (event) {
|
|
7390
|
+
event.stopPropagation()
|
|
7391
|
+
startVoice()
|
|
7392
|
+
})
|
|
7393
|
+
// 切走这个窗口(换到别的 App)就停止录音 —— 主会话也是这么做的:
|
|
7394
|
+
// 人已经不在说话了,继续占着麦克风只会录一串环境噪音。识别**不打断**(那时音频已经拿到了)。
|
|
7395
|
+
var offVoiceBlur = listen(window, 'blur', function () {
|
|
7396
|
+
if (voice.phase === 'recording') cancelVoice()
|
|
7397
|
+
})
|
|
7398
|
+
var offVoiceCancel = listen(voiceCancel, 'click', function (event) {
|
|
7399
|
+
event.stopPropagation()
|
|
7400
|
+
// feedback 阶段点 ✕ 只是"关掉这条提示",其余阶段是取消这次录音/识别。
|
|
7401
|
+
// 取消**不删字**:已经说出来的(含半句预览)一律留在输入框里。
|
|
7402
|
+
cancelVoice()
|
|
7403
|
+
})
|
|
7404
|
+
var offVoiceStop = listen(voiceStop, 'click', function (event) {
|
|
7405
|
+
event.stopPropagation()
|
|
7406
|
+
finishVoice()
|
|
7407
|
+
})
|
|
7408
|
+
paintVoice()
|
|
7409
|
+
// ═════════════════════════ 语音输入(结束) ═════════════════════════
|
|
7410
|
+
|
|
7411
|
+
// 用户自己滚消息区(滚轮/触摸)→ 不再强行把他拉回底部
|
|
7412
|
+
var offBodyWheel = listen(body, 'wheel', noteUserScroll)
|
|
7413
|
+
var offBodyTouch = listen(body, 'touchmove', noteUserScroll)
|
|
7414
|
+
var offBodyScroll = listen(body, 'scroll', noteBodyScroll)
|
|
7415
|
+
|
|
7416
|
+
var offClose = listen(closeButton, 'click', function (event) {
|
|
7417
|
+
event.stopPropagation()
|
|
7418
|
+
closePanel()
|
|
7419
|
+
})
|
|
7420
|
+
|
|
7421
|
+
var offDrag = listen(head, 'mousedown', function (event) {
|
|
7422
|
+
if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
|
|
7423
|
+
event.preventDefault()
|
|
7424
|
+
var rect = panel.getBoundingClientRect()
|
|
7425
|
+
var offsetX = event.clientX - rect.left
|
|
7426
|
+
var offsetY = event.clientY - rect.top
|
|
7427
|
+
var move = function (moveEvent) {
|
|
7428
|
+
panel.style.left = clamp(moveEvent.clientX - offsetX, 4, window.innerWidth - rect.width - 4) + 'px'
|
|
7429
|
+
panel.style.top = clamp(moveEvent.clientY - offsetY, 4, window.innerHeight - 40) + 'px'
|
|
7430
|
+
if (historyList.style.display === 'flex') placeHistoryList()
|
|
7431
|
+
}
|
|
7432
|
+
var up = function () {
|
|
7433
|
+
window.removeEventListener('mousemove', move, true)
|
|
7434
|
+
window.removeEventListener('mouseup', up, true)
|
|
7435
|
+
}
|
|
7436
|
+
window.addEventListener('mousemove', move, true)
|
|
7437
|
+
window.addEventListener('mouseup', up, true)
|
|
7438
|
+
})
|
|
7439
|
+
|
|
7440
|
+
// —— 调试钩子(自动化验证用) ——
|
|
7441
|
+
window.__dshSelectionExplain = {
|
|
7442
|
+
open: function (text, context, label, keyContext) {
|
|
7443
|
+
state.selection = { text: text, range: null, rect: null }
|
|
7444
|
+
// 和真实划词完全同一条路径(含本地缓存命中);keyContext 省略时与 context 相同
|
|
7445
|
+
openPanelWith(text, context || '', label || '调试', null, keyContext)
|
|
7446
|
+
return true
|
|
7447
|
+
},
|
|
7448
|
+
expand: function () {
|
|
7449
|
+
expandDetail()
|
|
7450
|
+
return true
|
|
7451
|
+
},
|
|
7452
|
+
parts: function () {
|
|
7453
|
+
return { ...state.parts, stage: state.stage }
|
|
7454
|
+
},
|
|
7455
|
+
promote: function () {
|
|
7456
|
+
promote()
|
|
7457
|
+
return true
|
|
7458
|
+
},
|
|
7459
|
+
ask: function (question) {
|
|
7460
|
+
ask(question)
|
|
7461
|
+
return true
|
|
7462
|
+
},
|
|
7463
|
+
turns: function () {
|
|
7464
|
+
return state.turns.slice()
|
|
7465
|
+
},
|
|
7466
|
+
/** 自检用:当前待发送的引用(引用区里那几张卡片)。 */
|
|
7467
|
+
quotes: function () {
|
|
7468
|
+
return state.quotes.map(function (quote) {
|
|
7469
|
+
return {
|
|
7470
|
+
id: quote.id,
|
|
7471
|
+
label: quote.label,
|
|
7472
|
+
text: quote.text,
|
|
7473
|
+
context: quote.context || '',
|
|
7474
|
+
contextChars: (quote.context || '').length,
|
|
7475
|
+
session: quote.session === true,
|
|
7476
|
+
}
|
|
7477
|
+
})
|
|
7478
|
+
},
|
|
7479
|
+
/** 自检用:手工加一段引用(等价于点了「❝ 引用」/「引用整条」)。 */
|
|
7480
|
+
quote: function (text, label, extra) {
|
|
7481
|
+
return addQuote(text, label || '调试', extra || {})
|
|
7482
|
+
},
|
|
7483
|
+
/** 自检用:某个节点上的选区会被标成什么出处(等价于点引用那一刻算出来的那个)。 */
|
|
7484
|
+
quoteLabel: function (node) {
|
|
7485
|
+
return node ? panelSourceLabel(node) : '(需要传节点)'
|
|
7486
|
+
},
|
|
7487
|
+
/** 自检用:引用 + 提问拼出来的那条消息原文(发给模型的就是它)。 */
|
|
7488
|
+
compose: function (quotes, question) {
|
|
7489
|
+
return composeQuestion(quotes || state.quotes, question)
|
|
7490
|
+
},
|
|
7491
|
+
/** 自检用:引用浮标与最近一次可引用选区的状态。 */
|
|
7492
|
+
quoteState: function () {
|
|
7493
|
+
return {
|
|
7494
|
+
visible: quoteButton.style.display === 'inline-flex',
|
|
7495
|
+
selection: state.quoteSelection
|
|
7496
|
+
? {
|
|
7497
|
+
text: state.quoteSelection.text,
|
|
7498
|
+
label: state.quoteSelection.label,
|
|
7499
|
+
source: state.quoteSelection.source,
|
|
7500
|
+
turnIndex: state.quoteSelection.turnIndex === undefined ? -1 : state.quoteSelection.turnIndex,
|
|
7501
|
+
hasRange: !!state.quoteSelection.range,
|
|
7502
|
+
}
|
|
7503
|
+
: null,
|
|
7504
|
+
panelOpen: panelOpen,
|
|
7505
|
+
resolving: state.resolvingQuotes === true,
|
|
7506
|
+
}
|
|
7507
|
+
},
|
|
7508
|
+
/** 自检用:引用浮标的节点(无头环境里页面可能挂了两棵树,用这个拿真正带监听的那个)。 */
|
|
7509
|
+
quoteNode: function () {
|
|
7510
|
+
return quoteButton
|
|
7511
|
+
},
|
|
7512
|
+
close: closePanel,
|
|
7513
|
+
/**
|
|
7514
|
+
* 自检用:语音输入的状态(阶段 / 提示 / 目录 / 最近一次转写)。
|
|
7515
|
+
* phase:idle / requesting / recording / transcribing / done
|
|
7516
|
+
*/
|
|
7517
|
+
voice: function () {
|
|
7518
|
+
return {
|
|
7519
|
+
phase: voice.phase,
|
|
7520
|
+
supported: recordingSupported(),
|
|
7521
|
+
/** 录音行出来了没有(出来了 = 工具行换成了 ✕ / 波形 / ■ 那一套)。 */
|
|
7522
|
+
capture: captureRow.getAttribute('data-show') === '1',
|
|
7523
|
+
/** 状态文案(请允许使用麦克风… / 识别中… / 出错原因)。 */
|
|
7524
|
+
activity: voiceActivityText.textContent,
|
|
7525
|
+
activityTone: voiceActivity.getAttribute('data-tone') || '',
|
|
7526
|
+
dot: voiceDot.style.display !== 'none',
|
|
7527
|
+
waveform: waveSvg.style.display !== 'none',
|
|
7528
|
+
stop: voiceStop.style.display !== 'none',
|
|
7529
|
+
/** 行内动作:'prepare'(准备模型)/ 'retry'(🎤 重录)/ ''。 */
|
|
7530
|
+
action: voice.action || '',
|
|
7531
|
+
maxSeconds: voice.maxSeconds || VOICE_FALLBACK_SECONDS,
|
|
7532
|
+
level: Number(voice.level.toFixed(3)),
|
|
7533
|
+
lastText: voice.lastText,
|
|
7534
|
+
/** 波形 80 根线的高度(自检/冒烟用:静音时全是 2px 的基线)。 */
|
|
7535
|
+
bars: waveBars.map(function (bar) {
|
|
7536
|
+
return Number(bar.node.getAttribute('y2')) - Number(bar.node.getAttribute('y1'))
|
|
7537
|
+
}),
|
|
7538
|
+
/** 实时字幕:定稿 / 半句预览 / 拍数 / 有没有被整段识别改写过。 */
|
|
7539
|
+
live: {
|
|
7540
|
+
active: voice.live.active,
|
|
7541
|
+
disabled: voice.live.disabled,
|
|
7542
|
+
reason: voice.live.reason,
|
|
7543
|
+
committed: voice.live.committed,
|
|
7544
|
+
preview: voice.live.preview,
|
|
7545
|
+
text: voice.live.text,
|
|
7546
|
+
passes: { preview: voice.live.passes.preview, commit: voice.live.passes.commit },
|
|
7547
|
+
rewritten: voice.live.rewritten,
|
|
7548
|
+
boundary: voice.live.boundary,
|
|
7549
|
+
phraseStart: voice.live.phraseStart,
|
|
7550
|
+
tap: voice.capture && voice.capture.tap && voice.capture.tap.supported()
|
|
7551
|
+
? {
|
|
7552
|
+
length: voice.capture.tap.length(),
|
|
7553
|
+
loudIndex: voice.capture.tap.loudIndex(),
|
|
7554
|
+
loudAt: voice.capture.tap.loudAt(),
|
|
7555
|
+
}
|
|
7556
|
+
: null,
|
|
7557
|
+
},
|
|
7558
|
+
catalog: voice.catalog
|
|
7559
|
+
? {
|
|
7560
|
+
available: voice.catalog.available === true,
|
|
7561
|
+
reason: voice.catalog.reason || '',
|
|
7562
|
+
error: voice.catalog.error || '',
|
|
7563
|
+
providers: (voice.catalog.providers || []).map(function (provider) {
|
|
7564
|
+
return { id: provider.id, name: provider.name, phase: provider.phase }
|
|
7565
|
+
}),
|
|
7566
|
+
}
|
|
7567
|
+
: null,
|
|
7568
|
+
}
|
|
7569
|
+
},
|
|
7570
|
+
/** 自检用:麦克风按钮节点(无头环境里页面可能挂了两棵树,用这个拿真正带监听的那个)。 */
|
|
7571
|
+
voiceNode: function () {
|
|
7572
|
+
return micButton
|
|
7573
|
+
},
|
|
7574
|
+
/** 自检用:手动催一拍实时字幕(测试里不用干等 1.3s 的节流)。 */
|
|
7575
|
+
voiceTick: function () {
|
|
7576
|
+
voice.live.tickAt = 0
|
|
7577
|
+
liveTick()
|
|
7578
|
+
return true
|
|
7579
|
+
},
|
|
7580
|
+
/** 自检用:录音行的各部件(✕ / 波形 / 状态 / 动作位 / ■)。 */
|
|
7581
|
+
voiceNodes: function () {
|
|
7582
|
+
return {
|
|
7583
|
+
row: captureRow,
|
|
7584
|
+
cancel: voiceCancel,
|
|
7585
|
+
wave: waveSvg,
|
|
7586
|
+
activity: voiceActivity,
|
|
7587
|
+
action: voiceActionSlot,
|
|
7588
|
+
stop: voiceStop,
|
|
7589
|
+
}
|
|
7590
|
+
},
|
|
7591
|
+
/** 自检用:读/写追问输入框(验证"转写文字插进输入框")。 */
|
|
7592
|
+
askValue: function (text) {
|
|
7593
|
+
if (text !== undefined) {
|
|
7594
|
+
askBox.value = String(text)
|
|
7595
|
+
refreshAskState()
|
|
7596
|
+
}
|
|
7597
|
+
return askBox.value
|
|
7598
|
+
},
|
|
7599
|
+
/** 自检用:清掉本地缓存,模拟"刷新页面后重开"(验证历史回放路径)。 */
|
|
7600
|
+
forget: function () {
|
|
7601
|
+
cache.clear()
|
|
7602
|
+
return true
|
|
7603
|
+
},
|
|
7604
|
+
state: function () {
|
|
7605
|
+
return {
|
|
7606
|
+
phase: state.phase,
|
|
7607
|
+
cache: state.cacheState,
|
|
7608
|
+
cacheKey: state.cacheKey.slice(0, 24),
|
|
7609
|
+
sameTextSeen: state.sameTextHint,
|
|
7610
|
+
chars: state.chars,
|
|
7611
|
+
/** host 回报的本次请求实际档位(等待提示用);和用户选的 effort 是两码事 */
|
|
7612
|
+
stageEffort: state.stageEffort,
|
|
7613
|
+
raw: state.raw,
|
|
7614
|
+
model: state.model,
|
|
7615
|
+
sections: splitSections(sanitizeToolResidue(state.raw)),
|
|
7616
|
+
toolResidue: state.toolResidue,
|
|
7617
|
+
tools: state.tools.length,
|
|
7618
|
+
toolBusy: state.toolBusy,
|
|
7619
|
+
toolDigest: state.toolDigest,
|
|
7620
|
+
/** 自检用:有没有在飞的请求 / 是不是"收起小窗中止"的那一轮。 */
|
|
7621
|
+
thought: state.thought,
|
|
7622
|
+
status: state.lastStatus,
|
|
7623
|
+
follow: state.follow,
|
|
7624
|
+
hasRequest: !!state.request,
|
|
7625
|
+
aborted: !!state.aborted,
|
|
7626
|
+
asking: !!state.asking,
|
|
7627
|
+
}
|
|
7628
|
+
},
|
|
7629
|
+
panel: function () {
|
|
7630
|
+
return panel
|
|
7631
|
+
},
|
|
7632
|
+
pill: function () {
|
|
7633
|
+
return pill
|
|
7634
|
+
},
|
|
7635
|
+
/** 自检用:残渣清理(供单测直接打表)。 */
|
|
7636
|
+
stripResidue: function (text) {
|
|
7637
|
+
return sanitizeToolResidue(text)
|
|
7638
|
+
},
|
|
4557
7639
|
/** 自检用:模型/档位当前值 + 已加载的目录规模。 */
|
|
4558
7640
|
modelState: function () {
|
|
4559
7641
|
return {
|
|
@@ -4632,13 +7714,64 @@ window.__ModuleLoader__.load({
|
|
|
4632
7714
|
resizing: function () {
|
|
4633
7715
|
return !!pillObserver
|
|
4634
7716
|
},
|
|
7717
|
+
/**
|
|
7718
|
+
* 自检用:侧边栏网页划词桥的状态。
|
|
7719
|
+
* frames 里的每一项对应一个"桥过的预览帧";own=true 表示那一帧的文档
|
|
7720
|
+
* 是我们自己重发过的 blob(交互式预览走这条)。
|
|
7721
|
+
*/
|
|
7722
|
+
bridge: function () {
|
|
7723
|
+
var frames = []
|
|
7724
|
+
bridged.forEach(function (url, frame) {
|
|
7725
|
+
frames.push({
|
|
7726
|
+
connected: frame.isConnected === true,
|
|
7727
|
+
own: !!url,
|
|
7728
|
+
srcdoc: frame.getAttribute('srcdoc') !== null,
|
|
7729
|
+
sandbox: frame.getAttribute('sandbox'),
|
|
7730
|
+
})
|
|
7731
|
+
})
|
|
7732
|
+
return {
|
|
7733
|
+
on: bridgeOn,
|
|
7734
|
+
frames: frames,
|
|
7735
|
+
active: !!state.bridgeActive,
|
|
7736
|
+
selection: state.selection && state.selection.source === 'iframe' ? state.selection.text : '',
|
|
7737
|
+
}
|
|
7738
|
+
},
|
|
7739
|
+
/** 自检用:扫一遍预览帧(等价于 DOM 变动后的那次自动扫描)。 */
|
|
7740
|
+
bridgeScan: function () {
|
|
7741
|
+
scanPreviewFrames()
|
|
7742
|
+
return window.__dshSelectionExplain.bridge()
|
|
7743
|
+
},
|
|
7744
|
+
/** 自检用:当前选区的来源与标签(source=iframe 表示来自侧边栏网页里的桥)。 */
|
|
7745
|
+
selection: function () {
|
|
7746
|
+
if (!state.selection) return null
|
|
7747
|
+
return {
|
|
7748
|
+
text: state.selection.text,
|
|
7749
|
+
source: state.selection.source || 'document',
|
|
7750
|
+
label: state.selection.label || '',
|
|
7751
|
+
context: state.selection.context || '',
|
|
7752
|
+
keyContext: state.selection.keyContext || '',
|
|
7753
|
+
}
|
|
7754
|
+
},
|
|
7755
|
+
/** 自检用:最近一次请求载荷的关键字段(标签 / 上下文 / 选中文字)。 */
|
|
7756
|
+
payload: function () {
|
|
7757
|
+
if (!state.payload) return null
|
|
7758
|
+
return {
|
|
7759
|
+
text: state.payload.text,
|
|
7760
|
+
label: state.payload.label,
|
|
7761
|
+
context: state.payload.context,
|
|
7762
|
+
kind: state.payload.kind,
|
|
7763
|
+
stage: state.payload.stage,
|
|
7764
|
+
}
|
|
7765
|
+
},
|
|
4635
7766
|
/** 模拟"刷新页面后":内存里那段小窗没了,只剩 host 上的历史。 */
|
|
4636
7767
|
reset: function () {
|
|
4637
7768
|
closePanel()
|
|
4638
7769
|
state.payload = null
|
|
4639
7770
|
state.selection = null
|
|
7771
|
+
state.bridgeActive = false
|
|
4640
7772
|
state.parts = { translation: '', detail: '' }
|
|
4641
7773
|
state.turns = []
|
|
7774
|
+
clearQuotes()
|
|
4642
7775
|
state.raw = ''
|
|
4643
7776
|
state.phase = 'idle'
|
|
4644
7777
|
state.error = ''
|
|
@@ -4668,11 +7801,14 @@ window.__ModuleLoader__.load({
|
|
|
4668
7801
|
}
|
|
4669
7802
|
return function () {
|
|
4670
7803
|
offPopEnd()
|
|
7804
|
+
offQuotePopEnd()
|
|
4671
7805
|
offMouseUp()
|
|
4672
7806
|
offKeyUp()
|
|
4673
7807
|
offMouseDown()
|
|
4674
7808
|
offButtonDown()
|
|
4675
7809
|
offButtonClick()
|
|
7810
|
+
offQuoteDown()
|
|
7811
|
+
offQuoteClick()
|
|
4676
7812
|
offScroll()
|
|
4677
7813
|
offResize()
|
|
4678
7814
|
offKeyDown()
|
|
@@ -4682,12 +7818,19 @@ window.__ModuleLoader__.load({
|
|
|
4682
7818
|
offAskKey()
|
|
4683
7819
|
offAskInput()
|
|
4684
7820
|
offAskSend()
|
|
7821
|
+
// 语音输入:摘掉麦克风监听,并把正在录/正在识别的这一轮彻底作废(松开麦克风)
|
|
7822
|
+
offMic()
|
|
7823
|
+
offVoiceCancel()
|
|
7824
|
+
offVoiceStop()
|
|
7825
|
+
offVoiceBlur()
|
|
7826
|
+
cancelVoice()
|
|
4685
7827
|
offClose()
|
|
4686
7828
|
offDrag()
|
|
4687
7829
|
offPillDown()
|
|
4688
7830
|
offPillClick()
|
|
4689
7831
|
offPillKey()
|
|
4690
7832
|
offPreviewHeight()
|
|
7833
|
+
offBridgeMessage()
|
|
4691
7834
|
offCellMd()
|
|
4692
7835
|
offCellWeb()
|
|
4693
7836
|
offPrefKeys()
|
|
@@ -4708,12 +7851,34 @@ window.__ModuleLoader__.load({
|
|
|
4708
7851
|
pillObserver = null
|
|
4709
7852
|
}
|
|
4710
7853
|
if (typeof offSlot === 'function') offSlot()
|
|
7854
|
+
// 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
|
|
7855
|
+
if (bridgeScanId) clearTimeout(bridgeScanId)
|
|
7856
|
+
bridgeScanId = 0
|
|
7857
|
+
if (bridgeObserver) {
|
|
7858
|
+
try {
|
|
7859
|
+
bridgeObserver.disconnect()
|
|
7860
|
+
} catch (error) {
|
|
7861
|
+
/* noop */
|
|
7862
|
+
}
|
|
7863
|
+
bridgeObserver = null
|
|
7864
|
+
}
|
|
7865
|
+
bridged.forEach(function (url) {
|
|
7866
|
+
if (!url) return
|
|
7867
|
+
try {
|
|
7868
|
+
URL.revokeObjectURL(url)
|
|
7869
|
+
} catch (error) {
|
|
7870
|
+
/* noop */
|
|
7871
|
+
}
|
|
7872
|
+
})
|
|
7873
|
+
bridged.clear()
|
|
4711
7874
|
state.turns = []
|
|
7875
|
+
state.quotes = []
|
|
4712
7876
|
if (state.request) state.request.abort()
|
|
4713
7877
|
for (var i = 0; i < timers.length; i++) clearTimeout(timers[i])
|
|
4714
7878
|
cache.clear()
|
|
4715
7879
|
if (layer.parentNode) layer.parentNode.removeChild(layer)
|
|
4716
7880
|
if (button.parentNode) button.parentNode.removeChild(button)
|
|
7881
|
+
if (quoteButton.parentNode) quoteButton.parentNode.removeChild(quoteButton)
|
|
4717
7882
|
if (panel.parentNode) panel.parentNode.removeChild(panel)
|
|
4718
7883
|
if (pill.parentNode) pill.parentNode.removeChild(pill)
|
|
4719
7884
|
if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
|
|
@@ -4871,6 +8036,66 @@ window.__ModuleLoader__.load({
|
|
|
4871
8036
|
return svg
|
|
4872
8037
|
}
|
|
4873
8038
|
|
|
8039
|
+
/** 取消图标(✕):两条斜线,和主会话录音行的取消键同形。 */
|
|
8040
|
+
function closeIcon() {
|
|
8041
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
8042
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
8043
|
+
svg.setAttribute('fill', 'none')
|
|
8044
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
8045
|
+
svg.setAttribute('stroke-width', '1.8')
|
|
8046
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
8047
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
8048
|
+
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8049
|
+
path.setAttribute('d', 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6')
|
|
8050
|
+
svg.appendChild(path)
|
|
8051
|
+
return svg
|
|
8052
|
+
}
|
|
8053
|
+
|
|
8054
|
+
/**
|
|
8055
|
+
* 麦克风图标:话筒头(圆角矩形)+ 底座弧 + 支架。
|
|
8056
|
+
* 用描边而不是实心:录音时这个位置要换成"红色实心停止键",
|
|
8057
|
+
* 平时轻一点,红起来的那一刻对比才够强。
|
|
8058
|
+
*/
|
|
8059
|
+
function micIcon() {
|
|
8060
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
8061
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
8062
|
+
svg.setAttribute('width', '14')
|
|
8063
|
+
svg.setAttribute('height', '14')
|
|
8064
|
+
svg.setAttribute('fill', 'none')
|
|
8065
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
8066
|
+
svg.setAttribute('stroke-width', '1.5')
|
|
8067
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
8068
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
8069
|
+
var cap = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8070
|
+
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')
|
|
8071
|
+
svg.appendChild(cap)
|
|
8072
|
+
var arc = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8073
|
+
arc.setAttribute('d', 'M3.6 7.2v.5a4.4 4.4 0 0 0 8.8 0v-.5')
|
|
8074
|
+
svg.appendChild(arc)
|
|
8075
|
+
var stem = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8076
|
+
stem.setAttribute('d', 'M8 12.1v2')
|
|
8077
|
+
svg.appendChild(stem)
|
|
8078
|
+
return svg
|
|
8079
|
+
}
|
|
8080
|
+
|
|
8081
|
+
/** 转圈图标(权限请求中 / 识别中):CSS 让它转起来。 */
|
|
8082
|
+
function spinnerIcon() {
|
|
8083
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
8084
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
8085
|
+
svg.setAttribute('width', '14')
|
|
8086
|
+
svg.setAttribute('height', '14')
|
|
8087
|
+
svg.setAttribute('fill', 'none')
|
|
8088
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
8089
|
+
svg.setAttribute('stroke-width', '1.7')
|
|
8090
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
8091
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
8092
|
+
var arc = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8093
|
+
// 缺一个口的圆:转起来才看得出在动
|
|
8094
|
+
arc.setAttribute('d', 'M8 1.8a6.2 6.2 0 1 1-4.4 1.8')
|
|
8095
|
+
svg.appendChild(arc)
|
|
8096
|
+
return svg
|
|
8097
|
+
}
|
|
8098
|
+
|
|
4874
8099
|
/** 发送图标(上箭头,和主会话 composer 一致)。 */
|
|
4875
8100
|
function sendIcon() {
|
|
4876
8101
|
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
@@ -4903,8 +8128,33 @@ window.__ModuleLoader__.load({
|
|
|
4903
8128
|
return svg
|
|
4904
8129
|
}
|
|
4905
8130
|
|
|
8131
|
+
/**
|
|
8132
|
+
* 引用浮标上的图标:两个引号块(❝ 的简化画法)。
|
|
8133
|
+
* 用圆 + 左下角甩出的小尾巴拼 —— 13px 下也认得出,不依赖系统有没有那个字形。
|
|
8134
|
+
*/
|
|
8135
|
+
function quoteIcon() {
|
|
8136
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
8137
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
8138
|
+
svg.setAttribute('fill', 'currentColor')
|
|
8139
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
8140
|
+
var marks = [
|
|
8141
|
+
{ cx: 5.4, cy: 6.1, tail: 'M3.9 7.4 2.1 12.2 6.1 10.2z' },
|
|
8142
|
+
{ cx: 11.6, cy: 6.1, tail: 'M10.1 7.4 8.3 12.2 12.3 10.2z' },
|
|
8143
|
+
]
|
|
8144
|
+
for (var i = 0; i < marks.length; i += 1) {
|
|
8145
|
+
var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
8146
|
+
dot.setAttribute('cx', String(marks[i].cx))
|
|
8147
|
+
dot.setAttribute('cy', String(marks[i].cy))
|
|
8148
|
+
dot.setAttribute('r', '2.3')
|
|
8149
|
+
svg.appendChild(dot)
|
|
8150
|
+
var tail = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
8151
|
+
tail.setAttribute('d', marks[i].tail)
|
|
8152
|
+
svg.appendChild(tail)
|
|
8153
|
+
}
|
|
8154
|
+
return svg
|
|
8155
|
+
}
|
|
8156
|
+
|
|
4906
8157
|
exports.apply = apply
|
|
4907
8158
|
exports.inject = ['slots']
|
|
4908
8159
|
return module.exports
|
|
4909
|
-
},
|
|
4910
|
-
})
|
|
8160
|
+
},})
|