@yfwu2020/dsh-selection-explain 0.9.0 → 0.10.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/README.md +70 -552
- package/lib/client.js +2029 -122
- package/lib/index.js +612 -188
- package/lib/index.js.map +1 -1
- package/lib/settings.js +3 -0
- package/lib/settings.js.map +1 -1
- package/lib/types/index.d.ts +90 -14
- package/native/mic-probe.c +160 -0
- package/package.json +5 -2
- package/scripts/build-vcard-lab.mjs +834 -0
- package/scripts/build.sh +17 -0
- package/scripts/fixtures/fake-mic-probe.sh +9 -0
- package/scripts/smoke-panel.mjs +271 -11
- package/scripts/test-client.mjs +1409 -54
- package/scripts/test-effort-map.mjs +129 -0
- package/scripts/test-effort-retry.mjs +109 -0
- package/scripts/test-escape-focus.mjs +66 -0
- package/scripts/test-filters.mjs +5 -88
- package/scripts/test-mic-idle.mjs +158 -0
- package/src/client/index.js +2029 -122
- package/src/index.ts +674 -179
- package/src/settings.ts +3 -0
package/src/client/index.js
CHANGED
|
@@ -51,6 +51,99 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
var SPEECH_API = '/selection-explain/api/speech'
|
|
52
52
|
var SPEECH_TRANSCRIBE = SPEECH_API + '/transcribe'
|
|
53
53
|
var SPEECH_PREPARE = SPEECH_API + '/prepare'
|
|
54
|
+
/**
|
|
55
|
+
* 麦克风占用("输入法正在语音输入"):host 读 CoreAudio 的进程对象列表,
|
|
56
|
+
* 见 src/index.ts 的 micPresence 与 native/mic-probe.c。
|
|
57
|
+
* → { ok, available, capturing, processes:[{pid,bundleId,name}], reason }
|
|
58
|
+
* 读它不需要麦克风授权 —— 插件不该为了一个"指示灯"去要用户的麦克风权限。
|
|
59
|
+
*/
|
|
60
|
+
var MIC_API = '/selection-explain/api/mic'
|
|
61
|
+
/**
|
|
62
|
+
* 认哪些输入法(bundleId 前缀)。豆包输入法。
|
|
63
|
+
* 加新输入法就往这里加一行:特性只对"已知输入法"生效,不去猜别的 App 在录什么。
|
|
64
|
+
*/
|
|
65
|
+
var IME_BUNDLES = ['com.bytedance.inputmethod.doubaoime']
|
|
66
|
+
/**
|
|
67
|
+
* 轮询间隔:只在"有选区"时问(浮标亮着的那几拍)。
|
|
68
|
+
*
|
|
69
|
+
* 200ms:它是"按 Fn → 卡片弹出"延迟里的两项之一(另一项是宿主探针的采样间隔,
|
|
70
|
+
* 见 src/index.ts 的 MIC_WATCH_MS)。原来 400ms 是保守估的,实测本机一次 GET
|
|
71
|
+
* 的开销可忽略,所以调快一倍、把感知延迟压下来。
|
|
72
|
+
* 注意:即使调快,也不能靠"检测到文字再弹框" —— 上屏发生在松手那一刻,
|
|
73
|
+
* 那时才抢焦点就晚了(字会落到别处);我们要的是"它开麦时就抢"。
|
|
74
|
+
*/
|
|
75
|
+
var MIC_POLL_MS = 200
|
|
76
|
+
/** 语音卡几何:右缘贴浮标右缘,左缘展开到正文列左边 —— 宽度由这两条边界算出来。 */
|
|
77
|
+
/**
|
|
78
|
+
* 卡片宽度:**固定 360**。
|
|
79
|
+
*
|
|
80
|
+
* 原来按"正文列左边 → 选区右缘"算,再夹在 [360, 560] 之间 —— 想法是"让卡片看起来属于
|
|
81
|
+
* 这一段正文",但实测**经常偏宽**(选区在第一行靠右结束时能顶到 560),读起来笨重。
|
|
82
|
+
* 现在统一 360:右缘仍然钉在选区右缘(和浮标同侧,形变时右缘不动、左缘展开),
|
|
83
|
+
* 宽度不再随选区位置变化。
|
|
84
|
+
*/
|
|
85
|
+
var VCARD_W = 360
|
|
86
|
+
/**
|
|
87
|
+
* **兜底**:松手之后等文字"静默"多久才认定定稿(毫秒)。
|
|
88
|
+
*
|
|
89
|
+
* 主信号是 compositionend(见 VOICE_SETTLE_COMPOSE_MS),实测豆包的智能整理都走它,
|
|
90
|
+
* 所以这条兜底实际上很少被用到 —— 它管两种情况:
|
|
91
|
+
* 1. 输入法直接改 value、没走合成 API;
|
|
92
|
+
* 2. 整理提交发生在客户端察觉松手之前(轮询有最多一个间隔的滞后,那时 compositionend 已经过去了)。
|
|
93
|
+
*
|
|
94
|
+
* 取 500(用户指定,原为 700):实测"松手 → 整理提交"是 71/257/328/393/515/600ms,
|
|
95
|
+
* 但那些**都带 compositionend**、走的是主信号,所以兜底提前到 500 不影响它们;
|
|
96
|
+
* 真要担心的是"没有合成信号"的场合 —— 那种情况没有实测数据,500 是个折中。
|
|
97
|
+
*/
|
|
98
|
+
var VOICE_SETTLE_MS = 500
|
|
99
|
+
/**
|
|
100
|
+
* 收到 compositionend 之后,再等多久才决定(毫秒)。
|
|
101
|
+
*
|
|
102
|
+
* 实测结论(8 次真实录音):豆包的智能整理**走合成 API 提交** —— 6 次里都有
|
|
103
|
+
* `inputType=insertCompositionText` + `compositionend`。所以它是**准确信号**,
|
|
104
|
+
* 不必再靠"猜静默窗口"。
|
|
105
|
+
*
|
|
106
|
+
* 留这 200ms 是因为可能连着提交两次(标点一次、改词一次):每收到一次就重新计时。
|
|
107
|
+
* 实测"松手 → 整理提交"= 71 / 257 / 328 / 393 / 515 / 600 ms;整理内容包含补标点、
|
|
108
|
+
* 去重复词、以及**改听错的词**("设置没法大概的话" → "设置没法打开的话")——
|
|
109
|
+
* 正因为会改词,绝不能一松手就发送。
|
|
110
|
+
*/
|
|
111
|
+
var VOICE_SETTLE_COMPOSE_MS = 200
|
|
112
|
+
/** 等定稿的**最长时间**(毫秒):整理一直不停的话,到点就按当前内容决定,不能无限等。 */
|
|
113
|
+
var VOICE_SETTLE_MAX_MS = 4000
|
|
114
|
+
/** 观察文字有没有变的间隔(毫秒)。用轮询而不是 input 事件:智能整理可能是直接改 value,不一定发事件。 */
|
|
115
|
+
var VOICE_SETTLE_POLL_MS = 200
|
|
116
|
+
/**
|
|
117
|
+
* 卡片 → 小窗的交接(用户选定的方案:**缩放长出**)。
|
|
118
|
+
*
|
|
119
|
+
* 以前这两步是"瞬切":卡片 `display:none`、小窗 `display:flex`,中间零过渡;
|
|
120
|
+
* 而且几何本身也换了 —— 卡片 360 宽、**右缘**贴选区右缘,小窗 540 宽、**左缘**贴选区左缘,
|
|
121
|
+
* 所以看起来是"跳"。现在让小窗**从卡片那一格开始**:起手用 transform 把面板压到卡片的矩形上,
|
|
122
|
+
* 260ms 回到自身(曲线与插件既有的"浮标 → 卡片"形变同一族)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么只动 transform(不动 width/left/top):那四个是布局属性,每帧都会重排整个面板
|
|
125
|
+
* (面板里有富文本、代码块,网页模式下还有 iframe)—— 形变期间会掉帧。transform 只走合成器。
|
|
126
|
+
* 代价是起手那几帧内容被压扁(缩放态),这正是"长出来"这个观感的来源。
|
|
127
|
+
*/
|
|
128
|
+
var PANEL_GROW_MS = 260
|
|
129
|
+
var PANEL_GROW_EASE = 'cubic-bezier(.2,.8,.2,1)'
|
|
130
|
+
/** 正文最多 4 行(约 93px),再多内部滚动并停在最新一行(与追问框同一套)。 */
|
|
131
|
+
var VCARD_TEXT_MAX = 96
|
|
132
|
+
/**
|
|
133
|
+
* 卡片高度 = 正文高度 + 这个(border-box 下的纵向内边距 8+8 与边框 1+1)。
|
|
134
|
+
* 单行卡因此是 24 + 18 = 42px —— 而不是漏掉 box-sizing 时渲染出来的 70px。
|
|
135
|
+
*/
|
|
136
|
+
var VCARD_PAD_Y = 18
|
|
137
|
+
/**
|
|
138
|
+
* "这一帧刚被动过"算多久以内有效(毫秒)。
|
|
139
|
+
*
|
|
140
|
+
* 只给侧边栏网页那条路用:焦点落在**不透明源子框架**里时,父文档的 `document.hasFocus()`
|
|
141
|
+
* 是否返回 true 由浏览器实现决定(跨引擎没有统一答案,headless 里也验不出来),
|
|
142
|
+
* 所以对帧内选区改用"这一帧刚刚报过动作(划词 / 按下)"当焦点证据 ——
|
|
143
|
+
* 桥报上来的那一刻,用户确实正在那个网页里操作。窗口取 5 秒:麦克风探针 125ms +
|
|
144
|
+
* 页面轮询 200ms,开麦检测最慢也就几百毫秒后才用到它。
|
|
145
|
+
*/
|
|
146
|
+
var FRAME_FOCUS_EVIDENCE_MS = 5000
|
|
54
147
|
/** 兜底限制(host 的 limits 优先;拿不到目录时按这套走)。 */
|
|
55
148
|
var VOICE_FALLBACK_SECONDS = 60
|
|
56
149
|
var VOICE_MAX_BYTES = 4 * 1024 * 1024
|
|
@@ -217,6 +310,22 @@ window.__ModuleLoader__.load({
|
|
|
217
310
|
return value < min ? min : value > max ? max : value
|
|
218
311
|
}
|
|
219
312
|
|
|
313
|
+
/**
|
|
314
|
+
* 选区是不是"指着一段已经不在文档里的文字"。
|
|
315
|
+
*
|
|
316
|
+
* 这是"选中的文字被删掉"的第二种形态,跟塌陷(`isCollapsed`)**不是**一回事:
|
|
317
|
+
* 节点被整个摘掉时(流式回答重渲染 / 消息被替换 / 内容区刷新),Firefox 里选区并不塌陷 ——
|
|
318
|
+
* `isCollapsed` 仍是 false、`toString()` 还吐得出那段旧文字,可那段文字屏幕上已经没有了。
|
|
319
|
+
* 只认塌陷的话浮标会赖着不走,点下去解读的是一段**不存在的文字**。
|
|
320
|
+
*
|
|
321
|
+
* 取不到 `isConnected`(老环境 / 无浏览器测试的桩 DOM)时一律当"还在":
|
|
322
|
+
* 宁可多留一会儿,也不误收一个正常选区。
|
|
323
|
+
*/
|
|
324
|
+
function selectionDetached(range) {
|
|
325
|
+
var node = range && range.startContainer
|
|
326
|
+
return !!node && node.isConnected === false
|
|
327
|
+
}
|
|
328
|
+
|
|
220
329
|
/** 选区是否落在输入框里(那里不该弹按钮,避免干扰输入)。 */
|
|
221
330
|
function insideEditable(node) {
|
|
222
331
|
var element = node && node.nodeType === 1 ? node : node && node.parentElement
|
|
@@ -934,6 +1043,125 @@ window.__ModuleLoader__.load({
|
|
|
934
1043
|
'.dsh-sel-sbtn[data-danger="1"]{color:var(--dsw-alias-state-error-primary,#e5484d);',
|
|
935
1044
|
'border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 45%,transparent)}',
|
|
936
1045
|
'.dsh-sel-sempty{font-size:12px;opacity:.55;padding:14px 2px}',
|
|
1046
|
+
// ── 语音卡:选中文字 + 输入法正在语音输入时,就地弹出的临时输入框 ──
|
|
1047
|
+
// 它由浮标**形变**而来(同一格 → 下移展开),所以几何全部由 JS 给,这里只管长相。
|
|
1048
|
+
// box-sizing 必须显式写:插件是按组件设的(panel/history/pill 各自写),没有全局重置。
|
|
1049
|
+
// 漏了它时 style.height 只是"内容高",加上内边距与边框真实高度会多出 24px ——
|
|
1050
|
+
// 单行卡于是渲染成 70px(用户报的"heard 阶段框太大"就是这个)。
|
|
1051
|
+
'.dsh-sel-vcard{position:fixed;z-index:2147483000;display:none;align-items:stretch;gap:11px;',
|
|
1052
|
+
'box-sizing:border-box;padding:8px 12px 8px 13px;background:var(--dsw-alias-bg-base,#fff);color:inherit;',
|
|
1053
|
+
'border:1px solid var(--dsw-alias-border-l2,rgba(140,140,140,.28));border-radius:20px;',
|
|
1054
|
+
'box-shadow:0 10px 28px rgba(0,0,0,.18);overflow:hidden;',
|
|
1055
|
+
'transition:width .3s cubic-bezier(.2,.8,.2,1),height .3s cubic-bezier(.2,.8,.2,1),',
|
|
1056
|
+
'left .3s cubic-bezier(.2,.8,.2,1),top .32s cubic-bezier(.2,.8,.2,1),',
|
|
1057
|
+
'border-radius .3s cubic-bezier(.2,.8,.2,1),border-color .2s ease}',
|
|
1058
|
+
// 「在听」= 卡片左缘那根 3px 竖条(B1)+ 高度呼吸(动1)。
|
|
1059
|
+
// 当年否掉竖条的理由是"太像输入光标"—— 但那次是**通高 + 紧贴文字**;
|
|
1060
|
+
// 现在保留同一形态,靠"伸缩"把它从"光标"读成"电平在动"(用户选定 B1+动1)。
|
|
1061
|
+
// 描边仍然泛青(静态的"这一段在用"),但**光晕呼吸撤掉**:指示器只留一个,别两个一起动。
|
|
1062
|
+
// 竖条在非"在听"时压暗到 .35(录音结束后不再假装在听,且不隐藏 —— 隐藏会让正文跳一下)。
|
|
1063
|
+
'.dsh-sel-vcard[data-voiced="1"]{border-color:var(--sel-a2)}',
|
|
1064
|
+
'.dsh-sel-vbar{flex:0 0 3px;width:3px;align-self:stretch;border-radius:3px;',
|
|
1065
|
+
'background:var(--sel-a1);margin-right:9px;opacity:.35}',
|
|
1066
|
+
'.dsh-sel-vcard[data-voiced="1"] .dsh-sel-vbar{opacity:1;',
|
|
1067
|
+
'animation:dsh-sel-vbar-pulse 1.05s ease-in-out infinite}',
|
|
1068
|
+
'@keyframes dsh-sel-vbar-pulse{50%{transform:scaleY(.55)}}',
|
|
1069
|
+
// 正文右侧留 40px 给发送键(它是绝对定位的,不参与行高 —— 否则 30px 的按钮会把
|
|
1070
|
+
// 单行卡片顶到 52px,用户看到的就是"临时框太高")
|
|
1071
|
+
'.dsh-sel-vcard-box{flex:1;min-width:0;align-self:center;display:block;margin:0;',
|
|
1072
|
+
// min-height:0 是防御:宿主 App 的样式表若给 textarea 设了 min-height(我们管不到别人的全局规则),
|
|
1073
|
+
// 高度就会由它说了算 —— 单行卡会因此变高
|
|
1074
|
+
'box-sizing:border-box;min-height:0;padding:0 40px 0 0;border:0;outline:0;background:transparent;color:inherit;',
|
|
1075
|
+
'resize:none;overflow-y:hidden;font:400 15.5px/1.5 ' + FONT + ';max-height:96px}',
|
|
1076
|
+
'.dsh-sel-vcard-box::placeholder{color:var(--dsw-alias-label-tertiary,rgba(140,140,140,.9))}',
|
|
1077
|
+
// 尺寸≈一行行高(23.25px):单行卡里它和文字同一条中线;多行时贴底=跟着最后一行走。
|
|
1078
|
+
// 比一行高太多(比如 30px)会让它在单行卡里"看起来掉到文字下面去了"。
|
|
1079
|
+
'.dsh-sel-vcard-send{position:absolute;right:12px;bottom:8px;width:26px;height:26px;border:0;',
|
|
1080
|
+
'border-radius:50%;background:var(--sel-a1);color:#fff;font:400 14px/1 ' + FONT + ';',
|
|
1081
|
+
'cursor:pointer;opacity:0;pointer-events:none;transition:opacity .16s ease}',
|
|
1082
|
+
// 还什么都没说时不摆出发送键(先只显示"正在听…")
|
|
1083
|
+
'.dsh-sel-vcard[data-text="1"] .dsh-sel-vcard-send{opacity:1;pointer-events:auto}',
|
|
1084
|
+
// ── 选中提示:下划线 + 从选区牵引到卡片的虚线(E 方案)──────────────
|
|
1085
|
+
// 抢焦点会让原生选区底纹消失,所以用这两样接手"指认这段文字"。
|
|
1086
|
+
// 两者都在我们自己的层里,**不碰选区 / 焦点语义**,对输入法上屏零影响。
|
|
1087
|
+
// 颜色直接用 --sel-a1 —— 它本来就按明暗换(浅色 #0d9488 / 深色 #4ecdc4)。
|
|
1088
|
+
'.dsh-sel-umark{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;',
|
|
1089
|
+
'z-index:2147482998;transition:opacity .14s ease}',
|
|
1090
|
+
'.dsh-sel-uline{position:fixed;height:2px;border-radius:1px;pointer-events:none}',
|
|
1091
|
+
// 牵引线是 **SVG 贝塞尔**(原来是一根 2px 竖条 → 控制点和端点同 x,退化成直线,
|
|
1092
|
+
// 用户一眼看出"这不是贝塞尔")。现在两端各给一个反向的横向控制量,做出可见的 S 形。
|
|
1093
|
+
'.dsh-sel-thread{position:fixed;left:0;top:0;width:100%;height:100%;pointer-events:none;',
|
|
1094
|
+
'z-index:2147482998;overflow:visible;display:none;opacity:0;transition:opacity .14s ease}',
|
|
1095
|
+
'.dsh-sel-thread path{fill:none;stroke-width:1.6;stroke-dasharray:4 4;',
|
|
1096
|
+
'animation:dsh-sel-thread-flow 1.1s linear infinite}',
|
|
1097
|
+
// 出现时"向卡片生长":用 clip 从上往下揭开(display:none → block 时动画自动重放,
|
|
1098
|
+
// 不需要 JS 去重置;连续开关卡片也不会第二次不动)
|
|
1099
|
+
'.dsh-sel-thread[data-grow="1"]{animation:dsh-sel-thread-grow .26s ease-out}',
|
|
1100
|
+
'@keyframes dsh-sel-thread-grow{from{clip-path:inset(0 0 100% 0)}to{clip-path:inset(0 0 0 0)}}',
|
|
1101
|
+
// 虚线朝卡片流动:SVG 直接动 stroke-dashoffset(比动 background-position 更省、也更准)
|
|
1102
|
+
'@keyframes dsh-sel-thread-flow{to{stroke-dashoffset:-16}}',
|
|
1103
|
+
// ── 消失:老式电视机关机(A 方案)────────────────────────────────
|
|
1104
|
+
// 卡片塌成一条线,亮线再横缩成一个点;最后一段做成"像素故障":
|
|
1105
|
+
// 亮线拆成 5 段,各自闪、各自抖、各自死;**收缩原点统一在整条线的中心**
|
|
1106
|
+
// (百分比是相对每段自己的宽度算的,所以外圈是 200% / -200% 这种值),
|
|
1107
|
+
// 于是"往中间收"和"错位露缝"同时发生 —— 那点电子损坏就是这么来的。
|
|
1108
|
+
// 为什么不用 mask 做虚线:mask-image 不是可动画属性,写在关键帧里会被忽略。
|
|
1109
|
+
// 亮线是**独立元素**,不是卡片的 box-shadow —— 后者会跟着 transform 一起被压扁。
|
|
1110
|
+
// 颜色由 JS 按明暗挂到容器上(浅色底白线看不见,只差 ~15/255),子元素用 currentColor 继承。
|
|
1111
|
+
'.dsh-sel-crtline{position:fixed;height:3px;pointer-events:none;z-index:2147483001;opacity:0}',
|
|
1112
|
+
'.dsh-sel-crtseg,.dsh-sel-crtspark{position:absolute;background:currentColor;border-radius:1px}',
|
|
1113
|
+
'.dsh-sel-crtseg{top:0;height:100%;width:20%}',
|
|
1114
|
+
'.dsh-sel-crtspark{top:-1px;width:3px;height:5px;opacity:0}',
|
|
1115
|
+
'.dsh-sel-vcard[data-off="a"]{transform-origin:50% 50%;animation:dsh-sel-crt-card-a 460ms cubic-bezier(.3,0,.2,1) forwards}',
|
|
1116
|
+
'.dsh-sel-crtline[data-off="a"]{animation:dsh-sel-crt-line-a 460ms cubic-bezier(.35,0,.25,1) forwards}',
|
|
1117
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg{animation:dsh-sel-crt-seg-a 460ms cubic-bezier(.4,0,.3,1) forwards}',
|
|
1118
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtspark{animation:dsh-sel-crt-spark-a 460ms linear forwards}',
|
|
1119
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg:nth-child(1){left:0%;transform-origin:200% 50%;animation-delay:-16ms}',
|
|
1120
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg:nth-child(2){left:20%;transform-origin:100% 50%;animation-delay:-38ms}',
|
|
1121
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg:nth-child(3){left:40%;transform-origin:0% 50%;animation-delay:-4ms}',
|
|
1122
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg:nth-child(4){left:60%;transform-origin:-100% 50%;animation-delay:-30ms}',
|
|
1123
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg:nth-child(5){left:80%;transform-origin:-200% 50%;animation-delay:-22ms}',
|
|
1124
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtspark:nth-child(6){left:31%;animation-delay:-46ms}',
|
|
1125
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtspark:nth-child(7){left:67%;animation-delay:-12ms}',
|
|
1126
|
+
// opacity 必须在前面显式写 1:只在 100% 写 0 的话,CSS 会从 0% 起就线性插值,
|
|
1127
|
+
// 整段塌缩都在同时淡出(实测 160ms 时 opacity 已经掉到 0.365),
|
|
1128
|
+
// 而显像管是"一直亮着、最后一下才没"。
|
|
1129
|
+
'@keyframes dsh-sel-crt-card-a{',
|
|
1130
|
+
'0%{transform:scale(1,1);filter:brightness(1);opacity:1}',
|
|
1131
|
+
'38%{transform:scale(1,1.06);filter:brightness(1.35);opacity:1}',
|
|
1132
|
+
'62%{transform:scale(1,.055);filter:brightness(2.2);opacity:1}',
|
|
1133
|
+
'88%{opacity:1}',
|
|
1134
|
+
'100%{transform:scale(.5,.05);filter:brightness(2.4);opacity:0}}',
|
|
1135
|
+
'@keyframes dsh-sel-crt-line-a{',
|
|
1136
|
+
'0%,52%{opacity:0}',
|
|
1137
|
+
'60%{opacity:1}',
|
|
1138
|
+
'96%{opacity:1}',
|
|
1139
|
+
'100%{opacity:0}}',
|
|
1140
|
+
// 每段:一边往中心收,一边闪断(opacity 阶梯)+ 上下抖 1px
|
|
1141
|
+
'@keyframes dsh-sel-crt-seg-a{',
|
|
1142
|
+
'0%,58%{transform:scaleX(1) translateY(0);opacity:1}',
|
|
1143
|
+
'64%{transform:scaleX(.94) translateY(1px);opacity:1}',
|
|
1144
|
+
'70%{transform:scaleX(.78) translateY(-1px);opacity:.3}',
|
|
1145
|
+
'74%{transform:scaleX(.66) translateY(0);opacity:1}',
|
|
1146
|
+
'80%{transform:scaleX(.44) translateY(1px);opacity:.15}',
|
|
1147
|
+
'84%{transform:scaleX(.34) translateY(0);opacity:1}',
|
|
1148
|
+
'90%{transform:scaleX(.16) translateY(-1px);opacity:.5}',
|
|
1149
|
+
'96%{transform:scaleX(.06) translateY(0);opacity:1}',
|
|
1150
|
+
'100%{transform:scaleX(0) translateY(0);opacity:0}}',
|
|
1151
|
+
// 火花:段子死掉之后还在原位闪两下的残留像素
|
|
1152
|
+
'@keyframes dsh-sel-crt-spark-a{',
|
|
1153
|
+
'0%,74%{opacity:0}',
|
|
1154
|
+
'78%{opacity:1}',
|
|
1155
|
+
'82%{opacity:0}',
|
|
1156
|
+
'88%{opacity:.9}',
|
|
1157
|
+
'94%{opacity:0}',
|
|
1158
|
+
'100%{opacity:0}}',
|
|
1159
|
+
'@media (prefers-reduced-motion:reduce){',
|
|
1160
|
+
'.dsh-sel-vcard{transition:none}.dsh-sel-vcard[data-voiced="1"] .dsh-sel-vbar{animation:none}',
|
|
1161
|
+
'.dsh-sel-vcard[data-off="a"],.dsh-sel-crtline[data-off="a"],',
|
|
1162
|
+
'.dsh-sel-crtline[data-off="a"] .dsh-sel-crtseg,.dsh-sel-crtline[data-off="a"] .dsh-sel-crtspark{',
|
|
1163
|
+
'animation:none!important;opacity:0}',
|
|
1164
|
+
'.dsh-sel-thread{animation:none}}',
|
|
937
1165
|
].join('')
|
|
938
1166
|
|
|
939
1167
|
// ────────────────────── Markdown 轻渲染(全 DOM,无 innerHTML) ──────────────────────
|
|
@@ -2187,7 +2415,6 @@ window.__ModuleLoader__.load({
|
|
|
2187
2415
|
/** 有工具正在跑(等待提示说"正在检索资料",不显示是哪个工具、查了什么)。 */
|
|
2188
2416
|
toolBusy: false,
|
|
2189
2417
|
/** 模型输出了伪造的工具调用格式(已被剥掉)。 */
|
|
2190
|
-
toolResidue: false,
|
|
2191
2418
|
/** 这次内容是"从历史回放"出来的(不是本次请求)。 */
|
|
2192
2419
|
fromHistory: false,
|
|
2193
2420
|
/** 当前选区的本地缓存 key。 */
|
|
@@ -2231,6 +2458,12 @@ window.__ModuleLoader__.load({
|
|
|
2231
2458
|
* 追问于是永远发 low —— 表现为"推理等级的设置没起作用"。
|
|
2232
2459
|
*/
|
|
2233
2460
|
stageEffort: '',
|
|
2461
|
+
/**
|
|
2462
|
+
* 首轮/详解期间 host 发的 notice(换档重试、上游偶发错误…)。
|
|
2463
|
+
* 以前首轮那条 SSE 处理器**没有 notice 分支**,host 说的话全被丢掉 ——
|
|
2464
|
+
* 用户只看到"卡了十几秒",不知道插件正在换档重试。
|
|
2465
|
+
*/
|
|
2466
|
+
notice: '',
|
|
2234
2467
|
/** 网页模式(小窗开关):复杂问题默认用网页回答。 */
|
|
2235
2468
|
webAnswer: false,
|
|
2236
2469
|
/** 追问是否正在流式返回。 */
|
|
@@ -2550,6 +2783,16 @@ window.__ModuleLoader__.load({
|
|
|
2550
2783
|
var settingsRailButtons = []
|
|
2551
2784
|
var settingsPanes = []
|
|
2552
2785
|
var settingsSegs = []
|
|
2786
|
+
/**
|
|
2787
|
+
* 四个格子的文案(关/低/高/最高)。
|
|
2788
|
+
*
|
|
2789
|
+
* 它是**插件的固定词汇**,与模型无关;落到模型的哪个真实档位由 host 按该模型的声明
|
|
2790
|
+
* 实时算(`/models` 每个模型带一份 `effortMap`,见 host 的 mapEffortToModel)。
|
|
2791
|
+
*/
|
|
2792
|
+
var SLOT_LABEL = { off: '关', low: '低', high: '高', max: '最高' }
|
|
2793
|
+
var SLOT_ORDER = ['off', 'low', 'high', 'max']
|
|
2794
|
+
/** 设置页里那几行"四格 → 当前模型真实档位"的提示节点(跟着模型实时变)。 */
|
|
2795
|
+
var settingsEffortHints = []
|
|
2553
2796
|
/** 自绘下拉的菜单节点(挂在抽屉上,重渲染时要一起清掉,否则会叠出第二份浮层)。 */
|
|
2554
2797
|
var settingsMenus = []
|
|
2555
2798
|
var settingsFetching = false
|
|
@@ -2696,6 +2939,7 @@ window.__ModuleLoader__.load({
|
|
|
2696
2939
|
if (forceRoute) entry.filled = false
|
|
2697
2940
|
fillModelMenu(entry, entry.catalog)
|
|
2698
2941
|
paintModelPick(entry)
|
|
2942
|
+
paintEffortMapHint()
|
|
2699
2943
|
})
|
|
2700
2944
|
fetchRoute(sessionId, isChat).then(applyFollowRoute)
|
|
2701
2945
|
}
|
|
@@ -2824,6 +3068,79 @@ window.__ModuleLoader__.load({
|
|
|
2824
3068
|
return (provider ? provider + ' / ' : '') + model
|
|
2825
3069
|
}
|
|
2826
3070
|
|
|
3071
|
+
/**
|
|
3072
|
+
* 设置页那行"四格 → 当前模型的真实档位"。
|
|
3073
|
+
*
|
|
3074
|
+
* 映射是 **host 按模型声明实时算的**:`/models` 里每个模型带一份 `effortMap`
|
|
3075
|
+
* (见 host 的 mapEffortToModel —— 它只吃"这个模型自己声明的档位列表",
|
|
3076
|
+
* 名字认不出就按位置插值,所以任何用户换任何模型都有落点)。
|
|
3077
|
+
* 客户端只负责显示,插件里**没有**任何按模型写死的表;换模型 / 换会话立刻跟着变。
|
|
3078
|
+
*/
|
|
3079
|
+
/** 模型自己给这个档位的名字(清单里没有就用 id)——「思考强度」那行映射提示专用。 */
|
|
3080
|
+
function modelTierNameOf(item, id) {
|
|
3081
|
+
var efforts = (item && item.efforts) || []
|
|
3082
|
+
for (var i = 0; i < efforts.length; i += 1) {
|
|
3083
|
+
if (efforts[i].id === id) return String(efforts[i].name || efforts[i].id)
|
|
3084
|
+
}
|
|
3085
|
+
return String(id)
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
/**
|
|
3089
|
+
* 改「追问」的思考强度 —— **唯一**的写入口(设置页那一行与小窗那个控件共用)。
|
|
3090
|
+
*
|
|
3091
|
+
* 两处读写的是**同一个值**:host 配置 `chatReasoningEffort`(四个格子之一)。
|
|
3092
|
+
* 于是不存在"两边各写各的",也不需要任何反向映射 —— 一边改,另一边重画一次就同步了。
|
|
3093
|
+
* `state.effort` 只是这个值的**内存镜像**(发请求时随 body.effort 带上,免得存盘还没回来
|
|
3094
|
+
* 就发了请求;列表读到 `stages.chat` 之前也靠它兜底)。
|
|
3095
|
+
*/
|
|
3096
|
+
function setChatSlot(slot) {
|
|
3097
|
+
settingsLive.chatReasoningEffort = slot
|
|
3098
|
+
state.effort = slot
|
|
3099
|
+
var entry = settingsByKey.chatReasoningEffort
|
|
3100
|
+
if (entry) {
|
|
3101
|
+
setControlValue('chatReasoningEffort', slot)
|
|
3102
|
+
paintSegPill(entry)
|
|
3103
|
+
}
|
|
3104
|
+
if (modelMenu.getAttribute('data-open') === '1') renderModelMenu()
|
|
3105
|
+
renderSettingsNote('')
|
|
3106
|
+
scheduleSettingsSave()
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
/** 当前「追问」档位:内存镜像 → 配置 → /models 报的配置值(都是四个格子之一)。 */
|
|
3110
|
+
function chatSlot() {
|
|
3111
|
+
return state.effort || settingsLive.chatReasoningEffort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function paintEffortMapHint() {
|
|
3115
|
+
if (!settingsEffortHints.length) return
|
|
3116
|
+
var choice = state.modelChoice
|
|
3117
|
+
if (!choice && modelCatalog.current && modelCatalog.current.provider && modelCatalog.current.model) {
|
|
3118
|
+
choice = { provider: String(modelCatalog.current.provider), model: String(modelCatalog.current.model) }
|
|
3119
|
+
}
|
|
3120
|
+
var item = choice ? catalogItemOf(choice.provider, choice.model) : null
|
|
3121
|
+
var text = ''
|
|
3122
|
+
if (!item) {
|
|
3123
|
+
text = modelCatalog.error
|
|
3124
|
+
? '读不到模型清单(' + modelCatalog.error + '):暂时按原档位发送,被拒时自动换档'
|
|
3125
|
+
: '正在读取该模型的档位…'
|
|
3126
|
+
} else if (item.hasReasoning === false) {
|
|
3127
|
+
text = '该模型不支持思考强度:这四个格子对它不生效(不会发送档位)'
|
|
3128
|
+
} else if (item.effortMap && item.effortMap[SLOT_ORDER[0]]) {
|
|
3129
|
+
var parts = []
|
|
3130
|
+
for (var i = 0; i < SLOT_ORDER.length; i += 1) {
|
|
3131
|
+
var slot = SLOT_ORDER[i]
|
|
3132
|
+
var actual = item.effortMap[slot]
|
|
3133
|
+
// 右边用**模型自己的档位名**(Off/Low/Xhigh…),不要换成我们的「低/高」——
|
|
3134
|
+
// 否则"关 → 低"这种看着像恒等映射,实际落到的是模型的 low 档
|
|
3135
|
+
parts.push(SLOT_LABEL[slot] + ' → ' + (actual ? modelTierNameOf(item, actual) : '—'))
|
|
3136
|
+
}
|
|
3137
|
+
text = '当前模型:' + parts.join(' · ')
|
|
3138
|
+
} else {
|
|
3139
|
+
text = '暂未读到该模型声明的档位:先按原档位发送,被拒时自动换档重试'
|
|
3140
|
+
}
|
|
3141
|
+
for (var h = 0; h < settingsEffortHints.length; h += 1) settingsEffortHints[h].textContent = text
|
|
3142
|
+
}
|
|
3143
|
+
|
|
2827
3144
|
function settingsDirtyCount() {
|
|
2828
3145
|
var n = 0
|
|
2829
3146
|
Object.keys(settingsLive).forEach(function (key) {
|
|
@@ -2866,6 +3183,8 @@ window.__ModuleLoader__.load({
|
|
|
2866
3183
|
})
|
|
2867
3184
|
updateSettingDependencies()
|
|
2868
3185
|
syncComposerModel()
|
|
3186
|
+
// 「追问」档位的内存镜像跟着配置走(四个格子之一;列表读到 stages.chat 之前也靠它兜底)
|
|
3187
|
+
state.effort = settingsLive.chatReasoningEffort || ''
|
|
2869
3188
|
applySelectionLimit(settingsLive)
|
|
2870
3189
|
if (!state.modelChoice) refreshModelRow(settingsByKey.modelChoice, true)
|
|
2871
3190
|
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
@@ -2890,6 +3209,7 @@ window.__ModuleLoader__.load({
|
|
|
2890
3209
|
syncComposerModel()
|
|
2891
3210
|
var entry = settingsByKey.modelChoice
|
|
2892
3211
|
if (entry) { paintModelPick(entry); repaintModelMenu(entry) }
|
|
3212
|
+
paintEffortMapHint()
|
|
2893
3213
|
loadSettings(false)
|
|
2894
3214
|
renderSettingsNote('')
|
|
2895
3215
|
scheduleSettingsSave()
|
|
@@ -2947,7 +3267,13 @@ window.__ModuleLoader__.load({
|
|
|
2947
3267
|
entry.cells[i].setAttribute('aria-checked', picked ? 'true' : 'false')
|
|
2948
3268
|
if (picked) on = entry.cells[i]
|
|
2949
3269
|
}
|
|
2950
|
-
|
|
3270
|
+
// 值不在四格里(小窗选了模型自己的 medium/xhigh 这种):一个格子都不点亮、
|
|
3271
|
+
// 滑块也收起来 —— 留着上一次的位置会假装它还选中着
|
|
3272
|
+
if (!on) {
|
|
3273
|
+
entry.pill.style.display = 'none'
|
|
3274
|
+
return
|
|
3275
|
+
}
|
|
3276
|
+
entry.pill.style.display = ''
|
|
2951
3277
|
entry.pill.style.left = String(on.offsetLeft) + 'px'
|
|
2952
3278
|
entry.pill.style.width = String(on.offsetWidth) + 'px'
|
|
2953
3279
|
}
|
|
@@ -3031,7 +3357,7 @@ window.__ModuleLoader__.load({
|
|
|
3031
3357
|
var entry = { spec: spec, row: row, seg: seg, pill: pill, cells: [] }
|
|
3032
3358
|
var options = spec.options || []
|
|
3033
3359
|
options.forEach(function (option) {
|
|
3034
|
-
var cell = el('button', 'dsh-sel-segcell',
|
|
3360
|
+
var cell = el('button', 'dsh-sel-segcell', SLOT_LABEL[option] || option)
|
|
3035
3361
|
cell.type = 'button'
|
|
3036
3362
|
cell.setAttribute('role', 'radio')
|
|
3037
3363
|
cell.setAttribute('data-value', option)
|
|
@@ -3040,7 +3366,8 @@ window.__ModuleLoader__.load({
|
|
|
3040
3366
|
seg.setAttribute('data-value', option)
|
|
3041
3367
|
paintSegPill(entry)
|
|
3042
3368
|
settingsLive[spec.key] = option
|
|
3043
|
-
|
|
3369
|
+
// 与小窗那个控件共用同一个写入口(同一份数据:配置里的 chatReasoningEffort)
|
|
3370
|
+
if (spec.key === 'chatReasoningEffort') { setChatSlot(option); paintModelPill() }
|
|
3044
3371
|
renderSettingsNote('')
|
|
3045
3372
|
scheduleSettingsSave()
|
|
3046
3373
|
})
|
|
@@ -3111,6 +3438,7 @@ window.__ModuleLoader__.load({
|
|
|
3111
3438
|
settingsRailButtons = []
|
|
3112
3439
|
settingsPanes = []
|
|
3113
3440
|
settingsSegs = []
|
|
3441
|
+
settingsEffortHints = []
|
|
3114
3442
|
for (var m = 0; m < settingsMenus.length; m += 1) {
|
|
3115
3443
|
if (settingsMenus[m].parentNode) settingsMenus[m].parentNode.removeChild(settingsMenus[m])
|
|
3116
3444
|
}
|
|
@@ -3140,6 +3468,14 @@ window.__ModuleLoader__.load({
|
|
|
3140
3468
|
if (item) block.appendChild(buildSettingRow(item))
|
|
3141
3469
|
})
|
|
3142
3470
|
if (part.note) block.appendChild(el('p', 'dsh-sel-ssectionnote', part.note))
|
|
3471
|
+
// 「思考强度」那一节多一行:这四个格子**现在**落到当前模型的哪些档位上。
|
|
3472
|
+
// 映射由 host 按模型声明实时算(换模型 / DSH 升级换 adapter 都会跟着变)。
|
|
3473
|
+
if (part.keys.indexOf('chatReasoningEffort') >= 0) {
|
|
3474
|
+
var effortHint = el('p', 'dsh-sel-ssectionnote dsh-sel-effortmap', '')
|
|
3475
|
+
effortHint.setAttribute('data-effortmap', '1')
|
|
3476
|
+
block.appendChild(effortHint)
|
|
3477
|
+
settingsEffortHints.push(effortHint)
|
|
3478
|
+
}
|
|
3143
3479
|
section.appendChild(block)
|
|
3144
3480
|
})
|
|
3145
3481
|
settingsBody.appendChild(section)
|
|
@@ -3151,6 +3487,7 @@ window.__ModuleLoader__.load({
|
|
|
3151
3487
|
updateSettingDependencies()
|
|
3152
3488
|
syncComposerModel()
|
|
3153
3489
|
applySelectionLimit(values)
|
|
3490
|
+
paintEffortMapHint()
|
|
3154
3491
|
showSettingsTab(groups.length ? groups[0].id : '')
|
|
3155
3492
|
}
|
|
3156
3493
|
|
|
@@ -3389,7 +3726,6 @@ window.__ModuleLoader__.load({
|
|
|
3389
3726
|
if (!payload || payload.ok === false) throw new Error('恢复默认失败')
|
|
3390
3727
|
applyServerValues(payload.values, sent)
|
|
3391
3728
|
state.effort = settingsLive.chatReasoningEffort || ''
|
|
3392
|
-
writeStore(EFFORT_KEY, state.effort)
|
|
3393
3729
|
applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
|
|
3394
3730
|
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
3395
3731
|
if (bridgeOn) scheduleBridgeScan()
|
|
@@ -3421,6 +3757,9 @@ window.__ModuleLoader__.load({
|
|
|
3421
3757
|
*/
|
|
3422
3758
|
/** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
|
|
3423
3759
|
var askFocusPending = false
|
|
3760
|
+
/** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
|
|
3761
|
+
/** 调试:最后一次"没聚焦"卡在哪一步(成功时是 'ok')。自动聚焦这类问题很难从外部看出来。 */
|
|
3762
|
+
var focusBail = ''
|
|
3424
3763
|
var offSlot = null
|
|
3425
3764
|
if (ctx.slots && typeof ctx.slots.inject === 'function') {
|
|
3426
3765
|
try {
|
|
@@ -3513,8 +3852,23 @@ window.__ModuleLoader__.load({
|
|
|
3513
3852
|
|
|
3514
3853
|
// —— 选区 → 浮标 ——
|
|
3515
3854
|
|
|
3855
|
+
/**
|
|
3856
|
+
* 排一次选区检查(下一拍执行)。
|
|
3857
|
+
*
|
|
3858
|
+
* 同一拍里的多次请求**合并成一次**:`selectionchange` 在打字 / 流式重绘时会连发很多下,
|
|
3859
|
+
* 每下都排一个计时器的话 `timers` 数组会一直长(文件里别处也刻意避开这种写法,
|
|
3860
|
+
* 见 schedulePillIdle 的注释)。合并是安全的 —— 检查读的是"当下"的选区,
|
|
3861
|
+
* 而不是排队那一刻的快照。
|
|
3862
|
+
*/
|
|
3863
|
+
var checkPending = false
|
|
3864
|
+
|
|
3516
3865
|
function scheduleCheck() {
|
|
3517
|
-
|
|
3866
|
+
if (checkPending) return
|
|
3867
|
+
checkPending = true
|
|
3868
|
+
later(function () {
|
|
3869
|
+
checkPending = false
|
|
3870
|
+
checkSelection()
|
|
3871
|
+
}, 0)
|
|
3518
3872
|
}
|
|
3519
3873
|
|
|
3520
3874
|
/**
|
|
@@ -3614,6 +3968,12 @@ window.__ModuleLoader__.load({
|
|
|
3614
3968
|
} catch (error) {
|
|
3615
3969
|
return
|
|
3616
3970
|
}
|
|
3971
|
+
// 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上
|
|
3972
|
+
if (selectionDetached(range)) {
|
|
3973
|
+
state.quoteSelection = null
|
|
3974
|
+
hideQuoteButton()
|
|
3975
|
+
return
|
|
3976
|
+
}
|
|
3617
3977
|
var text = String(selection.toString() || '')
|
|
3618
3978
|
if (!text.trim() || text.length > MAX_QUOTE_SELECTION) {
|
|
3619
3979
|
state.quoteSelection = null
|
|
@@ -3698,6 +4058,12 @@ window.__ModuleLoader__.load({
|
|
|
3698
4058
|
return
|
|
3699
4059
|
}
|
|
3700
4060
|
var range = selection.getRangeAt(0)
|
|
4061
|
+
// 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上,
|
|
4062
|
+
// 浮标不该留着(否则点下去解读的是一段不存在的文字)
|
|
4063
|
+
if (selectionDetached(range)) {
|
|
4064
|
+
hideButton()
|
|
4065
|
+
return
|
|
4066
|
+
}
|
|
3701
4067
|
if (insideEditable(range.startContainer)) {
|
|
3702
4068
|
hideButton()
|
|
3703
4069
|
return
|
|
@@ -3720,18 +4086,23 @@ window.__ModuleLoader__.load({
|
|
|
3720
4086
|
|
|
3721
4087
|
function showButton(rect) {
|
|
3722
4088
|
// 浮标也在同一套配色下:它的底色是"页面背景"(浮层自身透明),按它判深浅
|
|
3723
|
-
if (!panelOpen) layer.setAttribute('data-theme',
|
|
4089
|
+
if (!panelOpen) layer.setAttribute('data-theme', currentSurfaceTheme())
|
|
3724
4090
|
// 只在"浮现"那一次播动效;已经可见时(划选范围被拖动、键盘调整)只平移,避免一直闪
|
|
3725
4091
|
if (placeFloat(button, rect)) {
|
|
3726
4092
|
setButtonPop(false)
|
|
3727
4093
|
void button.offsetWidth // 强制重排,让动画能重播
|
|
3728
4094
|
setButtonPop(true)
|
|
3729
4095
|
}
|
|
4096
|
+
// 有选区了:开始看"输入法是不是正在语音输入"(幂等,重复调用无副作用)
|
|
4097
|
+
startMicWatch()
|
|
3730
4098
|
}
|
|
3731
4099
|
|
|
3732
4100
|
function hideButton() {
|
|
3733
4101
|
button.style.display = 'none'
|
|
3734
4102
|
setButtonPop(false)
|
|
4103
|
+
// 浮标收了就没什么可等的了 —— 但语音卡开着时**不能停**:
|
|
4104
|
+
// 卡片本身就是"检测到开麦"的结果,它还要靠轮询判断"说完了没有"
|
|
4105
|
+
if (!vcardState.open) stopMicWatch()
|
|
3735
4106
|
}
|
|
3736
4107
|
|
|
3737
4108
|
/**
|
|
@@ -3763,11 +4134,1041 @@ window.__ModuleLoader__.load({
|
|
|
3763
4134
|
void quoteButton.offsetWidth
|
|
3764
4135
|
setQuotePop(true)
|
|
3765
4136
|
}
|
|
4137
|
+
// 浮标亮着就盯"输入法有没有开麦"(幂等):这时开麦的语义是"引用这段 + 把光标移进小窗",
|
|
4138
|
+
// 见 autoQuoteFromVoice。没浮标就没人问 —— 探针也跟着停(它只在被问时活着)。
|
|
4139
|
+
startMicWatch()
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
// ══════════════════ 语音卡:选中文字 + 输入法正在语音输入 ══════════════════
|
|
4143
|
+
//
|
|
4144
|
+
// 场景:用户划选一段文字,同时正用输入法(豆包)对着麦克风说话。
|
|
4145
|
+
// 这时在那段文字**下方**弹一张临时输入框并自动聚焦 —— 输入法"上屏"的文字会直接
|
|
4146
|
+
// 落进这个框(IME 只认焦点元素),所以我们**不需要**读它的转写结果,也不需要任何
|
|
4147
|
+
// 输入法权限。点发送就跳过翻译与详解,直接进追问。
|
|
4148
|
+
//
|
|
4149
|
+
// 三条前提,缺一不可(缺了就完全不弹,不装作在等):
|
|
4150
|
+
// ① 选区在本文档里(原生 App 里的选区我们既看不到、也接不住上屏);
|
|
4151
|
+
// ② 占着麦克风的是已知输入法(IME_BUNDLES);
|
|
4152
|
+
// ③ 页面有焦点、且用户没在别处打字(否则会把人家在别处的听写抢过来)。
|
|
4153
|
+
//
|
|
4154
|
+
// 时序是整件事成立的关键:检测到开麦(≤400ms)→ 弹卡 + 抢焦点 → 用户继续说 →
|
|
4155
|
+
// **松手时**输入法才上屏。抢焦点发生在开始时、上屏发生在结束时,中间有几秒余量;
|
|
4156
|
+
// 反过来"等检测到文字再弹框"就已经晚了 —— 字会落到原来那个焦点里去。
|
|
4157
|
+
//
|
|
4158
|
+
// ── 两条路,按"小窗开没开"分(2026-10-09 起)──
|
|
4159
|
+
// · 小窗**没开**:上面这套语音卡(选区是"要解读的对象",说完进一个新的追问)。
|
|
4160
|
+
// · 小窗**开着**:划词浮出的是「❝ 引用」浮标,这时开麦等于**点一下那个浮标** ——
|
|
4161
|
+
// 选中的文字挂进小窗、光标移到小窗输入框,输入法上屏的字直接落进小窗,
|
|
4162
|
+
// 和引用一起成为下一条追问(`autoQuoteFromVoice`)。
|
|
4163
|
+
// 两种情形互斥:面板一开,`state.selection` 就被清成 null,语音卡那套根本不启动。
|
|
4164
|
+
// 轮询也只跟着浮标走(浮标亮着才问 /api/mic),没浮标就没人问。
|
|
4165
|
+
|
|
4166
|
+
var vcard = null
|
|
4167
|
+
var vcardBox = null
|
|
4168
|
+
var vcardSend = null
|
|
4169
|
+
/** 关机动画那条亮线(懒建)。 */
|
|
4170
|
+
var crtLine = null
|
|
4171
|
+
/**
|
|
4172
|
+
* 发送那一刻卡片的矩形(视口坐标)—— 小窗据此"从那一格长出来"。
|
|
4173
|
+
* 只在 sendVCard 里写、只在 showPanel 里消费(用完即清):其余开窗路径(划词解读 /
|
|
4174
|
+
* 历史回放 / 升格)这里是 null,因此不受影响、保持原来的"瞬开"。
|
|
4175
|
+
*/
|
|
4176
|
+
var vcardHandoff = null
|
|
4177
|
+
/** 交接动画的兜底定时器与 transitionend 句柄(收尾要把内联的 transform/transition 清干净)。 */
|
|
4178
|
+
var panelGrowTimer = null
|
|
4179
|
+
var panelGrowHandler = null
|
|
4180
|
+
/**
|
|
4181
|
+
* 这一次录音周期里,"开麦 → 自动加引用 + 移光标"处理过没有。
|
|
4182
|
+
*
|
|
4183
|
+
* 为什么要这个:麦克风是每 200ms 问一次的,同一次录音会被问到十几遍 ——
|
|
4184
|
+
* 不抑制的话引用会被反复加(虽然 addQuote 对重复文本会挡掉,但 hideQuoteButton /
|
|
4185
|
+
* focusAskBox 会被反复执行,输入法的输入上下文也会被反复重绑)。
|
|
4186
|
+
* 与 vcardState.suppress 同一套思路:录音**停止**时复位(见 micTick 的 !matchingIme 分支)。
|
|
4187
|
+
*/
|
|
4188
|
+
var quoteVoiceHandled = false
|
|
4189
|
+
/** 选中提示:下划线层 / 牵引线 / 牵引线起点的小圆点(懒建)。 */
|
|
4190
|
+
var selMark = null
|
|
4191
|
+
var selThread = null
|
|
4192
|
+
var selThreadPath = null
|
|
4193
|
+
var selThreadDot = null
|
|
4194
|
+
var vcardState = {
|
|
4195
|
+
open: false,
|
|
4196
|
+
/** 打开时的选区快照(滚动 / 改窗口大小时按它重算位置)。 */
|
|
4197
|
+
range: null,
|
|
4198
|
+
/**
|
|
4199
|
+
* 打开时的锚点矩形(视口坐标)。
|
|
4200
|
+
*
|
|
4201
|
+
* 侧边栏网页那条路**没有 Range**(不透明源帧里的选区拿不出来),定位只能靠桥换算好的
|
|
4202
|
+
* 矩形 —— 新的 selection 消息会刷新它,于是帧内滚动 / 重报时卡片跟着走。
|
|
4203
|
+
*/
|
|
4204
|
+
anchorRect: null,
|
|
4205
|
+
timer: null,
|
|
4206
|
+
/** 上一次 /mic 还没回来(不叠请求)。 */
|
|
4207
|
+
busy: false,
|
|
4208
|
+
/**
|
|
4209
|
+
* 用户刚取消过这次录音(Esc / 点空白):在**这次录音结束之前**不再自动弹卡片。
|
|
4210
|
+
* 没有它的话:Esc 收卡片 → 恢复浮标 → 下一拍轮询发现"还在录" → 卡片又弹回来,
|
|
4211
|
+
* Esc 看起来像失灵(实测就是这么暴露出来的)。等 !capturing 才解除。
|
|
4212
|
+
*/
|
|
4213
|
+
suppress: false,
|
|
4214
|
+
/** 形变期间钉住正文宽度用的兜底定时器 / transitionend 句柄。 */
|
|
4215
|
+
unpinTimer: null,
|
|
4216
|
+
unpinHandler: null,
|
|
4217
|
+
/** 正在播关机动画(这段时间内卡片还没真正隐藏)。 */
|
|
4218
|
+
closing: false,
|
|
4219
|
+
/** 关机动画的兜底定时器 / animationend 句柄。 */
|
|
4220
|
+
crtTimer: null,
|
|
4221
|
+
crtHandler: null,
|
|
4222
|
+
/** "等文字定稿"的观察(松手后豆包还会智能整理,见 VOICE_SETTLE_MS)。 */
|
|
4223
|
+
settleTimer: null,
|
|
4224
|
+
settleLastText: '',
|
|
4225
|
+
settleStableAt: 0,
|
|
4226
|
+
settleDeadline: 0,
|
|
4227
|
+
/** 收到 compositionend 后的短定时器(见 VOICE_SETTLE_COMPOSE_MS)。 */
|
|
4228
|
+
settleComposeTimer: null,
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
function ensureVCard() {
|
|
4232
|
+
if (vcard) return
|
|
4233
|
+
vcard = el('div', 'dsh-sel-vcard')
|
|
4234
|
+
vcard.setAttribute('data-voiced', '0')
|
|
4235
|
+
vcard.setAttribute('data-text', '0')
|
|
4236
|
+
// 「在听」那根 3px 竖条:作为第一个 flex 子元素,正文排在它右边(占 3+9=12px)
|
|
4237
|
+
vcard.appendChild(el('div', 'dsh-sel-vbar'))
|
|
4238
|
+
vcardBox = el('textarea', 'dsh-sel-vcard-box')
|
|
4239
|
+
vcardBox.rows = 1
|
|
4240
|
+
vcardBox.placeholder = '正在听…'
|
|
4241
|
+
vcardBox.setAttribute('spellcheck', 'false')
|
|
4242
|
+
vcardSend = el('button', 'dsh-sel-vcard-send')
|
|
4243
|
+
vcardSend.type = 'button'
|
|
4244
|
+
vcardSend.title = '直接追问(跳过翻译与详解)'
|
|
4245
|
+
vcardSend.textContent = '↑'
|
|
4246
|
+
vcard.appendChild(vcardBox)
|
|
4247
|
+
vcard.appendChild(vcardSend)
|
|
4248
|
+
layer.appendChild(vcard)
|
|
4249
|
+
listen(vcardBox, 'input', syncVCardText)
|
|
4250
|
+
// 输入法上屏有的环境只发 compositionend 而不发 input:补一次(幂等)
|
|
4251
|
+
listen(vcardBox, 'compositionend', syncVCardText)
|
|
4252
|
+
// 「等定稿」的主信号:输入法的智能整理**走合成 API 提交**(实测 8 次录音里 6 次都有
|
|
4253
|
+
// inputType=insertCompositionText + compositionend)。收到就在短窗口后决定,
|
|
4254
|
+
// 不必等兜底的 500ms 静默;连着来两次(标点一次、改词一次)就重新计时。
|
|
4255
|
+
listen(vcardBox, 'compositionend', function () {
|
|
4256
|
+
if (!vcardState.settleTimer) return
|
|
4257
|
+
if (vcardState.settleComposeTimer) clearTimeout(vcardState.settleComposeTimer)
|
|
4258
|
+
vcardState.settleComposeTimer = setTimeout(function () {
|
|
4259
|
+
vcardState.settleComposeTimer = null
|
|
4260
|
+
if (!vcardState.open || !vcardState.settleTimer) return
|
|
4261
|
+
var settled = String((vcardBox && vcardBox.value) || '')
|
|
4262
|
+
stopVoiceSettle()
|
|
4263
|
+
finishVoiceSettle(settled)
|
|
4264
|
+
}, VOICE_SETTLE_COMPOSE_MS)
|
|
4265
|
+
})
|
|
4266
|
+
listen(vcardBox, 'keyup', syncVCardText)
|
|
4267
|
+
listen(vcardBox, 'keydown', function (event) {
|
|
4268
|
+
// Enter 发送、Shift+Enter 换行;输入法确认候选词的那个 Enter 不算(与追问框同一套判断)
|
|
4269
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
|
|
4270
|
+
event.preventDefault()
|
|
4271
|
+
sendVCard()
|
|
4272
|
+
}
|
|
4273
|
+
})
|
|
4274
|
+
listen(vcardSend, 'click', function (event) {
|
|
4275
|
+
event.preventDefault()
|
|
4276
|
+
event.stopPropagation()
|
|
4277
|
+
sendVCard()
|
|
4278
|
+
})
|
|
4279
|
+
// 卡片里的鼠标动作别冒泡出去:外面那套"点别处就收浮标"的逻辑会把卡收掉
|
|
4280
|
+
listen(vcard, 'mousedown', function (event) {
|
|
4281
|
+
event.stopPropagation()
|
|
4282
|
+
})
|
|
4283
|
+
// ④ 点屏幕空白处收卡片。用 capture:卡片自己的 mousedown 是冒泡阶段,
|
|
4284
|
+
// 这里先跑,所以判断"在不在卡片里"必须用 contains 而不是靠冒泡顺序。
|
|
4285
|
+
listen(document, 'mousedown', function (event) {
|
|
4286
|
+
if (!vcardState.open) return
|
|
4287
|
+
var target = event.target
|
|
4288
|
+
if (target && (target === vcard || (vcard.contains && vcard.contains(target)))) return
|
|
4289
|
+
vcardState.suppress = true
|
|
4290
|
+
closeVCard(true)
|
|
4291
|
+
}, true)
|
|
4292
|
+
listen(window, 'scroll', function () {
|
|
4293
|
+
if (vcardState.open) repositionVCard()
|
|
4294
|
+
}, true)
|
|
4295
|
+
listen(window, 'resize', function () {
|
|
4296
|
+
if (vcardState.open) repositionVCard()
|
|
4297
|
+
})
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
/** 有字了才摆出发送键;高度跟着内容长(最多 4 行,再多内部滚动并停在最新一行)。 */
|
|
4301
|
+
function syncVCardText() {
|
|
4302
|
+
if (!vcard || !vcardBox) return
|
|
4303
|
+
vcard.setAttribute('data-text', String(vcardBox.value || '').trim() ? '1' : '0')
|
|
4304
|
+
growVCardBox()
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
/**
|
|
4308
|
+
* 形变结束后解开正文宽度(回到 flex 布局)。
|
|
4309
|
+
*
|
|
4310
|
+
* 形变期间正文宽度被钉在终值(见 openVCard ②):卡片变宽只是"揭开"它。
|
|
4311
|
+
* 不解开的话,之后打字时正文宽度不会再跟着卡片走。
|
|
4312
|
+
*/
|
|
4313
|
+
function unpinVCardBox() {
|
|
4314
|
+
if (vcardState.unpinTimer) {
|
|
4315
|
+
clearTimeout(vcardState.unpinTimer)
|
|
4316
|
+
vcardState.unpinTimer = null
|
|
4317
|
+
}
|
|
4318
|
+
if (vcard && vcardState.unpinHandler) {
|
|
4319
|
+
vcard.removeEventListener('transitionend', vcardState.unpinHandler)
|
|
4320
|
+
vcardState.unpinHandler = null
|
|
4321
|
+
}
|
|
4322
|
+
if (!vcardBox) return
|
|
4323
|
+
vcardBox.style.flex = ''
|
|
4324
|
+
vcardBox.style.width = ''
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
/**
|
|
4328
|
+
* 排一次"解开正文宽度"。
|
|
4329
|
+
*
|
|
4330
|
+
* transitionend 是主路径;另挂一个兜底定时器 —— 形变被打断(切窗口、立刻关卡片)时
|
|
4331
|
+
* transitionend 可能永远不来,正文宽度就会一直钉着。
|
|
4332
|
+
*/
|
|
4333
|
+
function scheduleVCardUnpin() {
|
|
4334
|
+
if (!vcard) return
|
|
4335
|
+
if (vcardState.unpinTimer) {
|
|
4336
|
+
clearTimeout(vcardState.unpinTimer)
|
|
4337
|
+
vcardState.unpinTimer = null
|
|
4338
|
+
}
|
|
4339
|
+
if (vcardState.unpinHandler) {
|
|
4340
|
+
vcard.removeEventListener('transitionend', vcardState.unpinHandler)
|
|
4341
|
+
vcardState.unpinHandler = null
|
|
4342
|
+
}
|
|
4343
|
+
vcardState.unpinHandler = function (event) {
|
|
4344
|
+
if (event.target !== vcard) return
|
|
4345
|
+
if (event.propertyName === 'width') unpinVCardBox()
|
|
4346
|
+
// ⚠️ 这里**不要**重画选中提示。
|
|
4347
|
+
// 曾经加过"形变结束后重画一次"当安全网,结果是**闪一下**:
|
|
4348
|
+
// 重画会先 clearSelectionMarkers()(牵引线 display:none)再重画(block),
|
|
4349
|
+
// 而 display:none → block 会让生长动画**重放** —— 用户看到的就是"曲线生成后闪一下、
|
|
4350
|
+
// 再重新长一遍"。而且卡片形变会分别触发 top 和 height 两次,所以闪两次。
|
|
4351
|
+
// 现在终点用的是算出来的**最终位置**(不读卡片当下的 rect),本来就不需要这次重画。
|
|
4352
|
+
}
|
|
4353
|
+
vcard.addEventListener('transitionend', vcardState.unpinHandler)
|
|
4354
|
+
vcardState.unpinTimer = later(unpinVCardBox, 420)
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
function growVCardBox() {
|
|
4358
|
+
if (!vcardBox || !vcard) return
|
|
4359
|
+
vcardBox.style.height = 'auto'
|
|
4360
|
+
var content = Math.max(24, Math.min(vcardBox.scrollHeight, VCARD_TEXT_MAX))
|
|
4361
|
+
vcardBox.style.height = content + 'px'
|
|
4362
|
+
vcardBox.style.overflowY = vcardBox.scrollHeight > VCARD_TEXT_MAX ? 'auto' : 'hidden'
|
|
4363
|
+
if (vcardBox.scrollHeight > VCARD_TEXT_MAX) vcardBox.scrollTop = vcardBox.scrollHeight
|
|
4364
|
+
vcard.style.height = (content + VCARD_PAD_Y) + 'px'
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
/**
|
|
4368
|
+
* 卡片几何:右缘钉在浮标右缘(横向不跳),左缘展开到**正文列左边**。
|
|
4369
|
+
* 宽度不是拍脑袋定的 —— 它是"浮标右缘 − 正文列左边",所以展开后左缘正好和正文对齐。
|
|
4370
|
+
* 纵向落在整块文字下方(不是选中那一行下方):否则会压住下面那行正文。
|
|
4371
|
+
*/
|
|
4372
|
+
function vcardGeometry() {
|
|
4373
|
+
var range = vcardState.range
|
|
4374
|
+
var rect = null
|
|
4375
|
+
if (range) {
|
|
4376
|
+
try {
|
|
4377
|
+
rect = range.getBoundingClientRect()
|
|
4378
|
+
} catch (error) {
|
|
4379
|
+
rect = null
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
// 侧边栏网页那条路没有 Range:锚点用桥换算好的视口矩形(两者都是视口坐标,可直接混用)。
|
|
4383
|
+
if ((!rect || (!rect.width && !rect.height)) && vcardState.anchorRect) rect = vcardState.anchorRect
|
|
4384
|
+
if (!rect || (!rect.width && !rect.height)) return null
|
|
4385
|
+
// 帧内选区只知道自己那一段("块"是帧内的事,父页面看不见):整块就用它自己,
|
|
4386
|
+
// 于是"落在整段文字下方"这条规则在网页里退化成"落在这段选区下方"。
|
|
4387
|
+
var blockRect = (range && selectionBlockRect(range)) || rect
|
|
4388
|
+
var right = Math.min(rect.right, Math.max(8, window.innerWidth - 8))
|
|
4389
|
+
var width = VCARD_W
|
|
4390
|
+
var left = Math.max(8, right - width)
|
|
4391
|
+
// ⚠️ 纵向必须按**整个选区**的底边算,不能只按"选区起点所在的块":
|
|
4392
|
+
// 选中跨多个段落 / 代码块时,起点那个块的底边落在选区**中间**,
|
|
4393
|
+
// 卡片就会压在剩下的选中文字上(用户实测:"选大段文字时卡片有概率遮住文字")。
|
|
4394
|
+
var below = Math.max(blockRect.bottom, rect.bottom) + 22
|
|
4395
|
+
var top = below
|
|
4396
|
+
var cardHeight = 42
|
|
4397
|
+
try {
|
|
4398
|
+
var own = vcard && vcard.getBoundingClientRect()
|
|
4399
|
+
if (own && own.height) cardHeight = own.height
|
|
4400
|
+
} catch (error) {
|
|
4401
|
+
/* 读不到就按单行 42 估 */
|
|
4402
|
+
}
|
|
4403
|
+
// 下方放不下就翻到选区上方;上方也放不下(选区比视口还高)就贴视口底边 ——
|
|
4404
|
+
// 宁可压在可见文字的下缘,也不要把卡片推到屏幕外(那样等于没弹)
|
|
4405
|
+
if (below + cardHeight > window.innerHeight - 8) {
|
|
4406
|
+
var above = rect.top - cardHeight - 22
|
|
4407
|
+
top = above >= 8 ? above : Math.max(8, window.innerHeight - cardHeight - 16)
|
|
4408
|
+
}
|
|
4409
|
+
return { left: left, top: top, width: width, height: cardHeight }
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
/** 选区所在的那个"块"(段落 / 列表项 / 单元格)—— 卡片挂在它的下方。 */
|
|
4413
|
+
function selectionBlockRect(range) {
|
|
4414
|
+
var node = range.startContainer
|
|
4415
|
+
var element = node && node.nodeType === 1 ? node : node && node.parentElement
|
|
4416
|
+
while (element && element !== document.body && element !== document.documentElement) {
|
|
4417
|
+
var display = ''
|
|
4418
|
+
try {
|
|
4419
|
+
display = window.getComputedStyle(element).display
|
|
4420
|
+
} catch (error) {
|
|
4421
|
+
display = ''
|
|
4422
|
+
}
|
|
4423
|
+
if (display === 'block' || display === 'list-item' || display === 'table-cell') {
|
|
4424
|
+
return element.getBoundingClientRect()
|
|
4425
|
+
}
|
|
4426
|
+
element = element.parentElement
|
|
4427
|
+
}
|
|
4428
|
+
return null
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
function applyVCardGeometry(geo) {
|
|
4432
|
+
if (!vcard || !geo) return
|
|
4433
|
+
vcard.style.left = Math.round(geo.left) + 'px'
|
|
4434
|
+
vcard.style.top = Math.round(geo.top) + 'px'
|
|
4435
|
+
vcard.style.width = Math.round(geo.width) + 'px'
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
/** 滚动 / 改窗口时重新贴住那段文字(关掉过渡,免得跟着滚"飘")。 */
|
|
4439
|
+
function repositionVCard() {
|
|
4440
|
+
var geo = vcardGeometry()
|
|
4441
|
+
if (!vcard || !geo) return
|
|
4442
|
+
vcard.style.transition = 'none'
|
|
4443
|
+
applyVCardGeometry(geo)
|
|
4444
|
+
void vcard.offsetWidth
|
|
4445
|
+
vcard.style.transition = ''
|
|
4446
|
+
// 选中提示是按视口坐标摆的,滚动 / 改窗口要跟着重画
|
|
4447
|
+
if (vcardState.open) paintSelectionMarkers(false)
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
/**
|
|
4451
|
+
* 从浮标那一格**形变**过来:先按浮标的几何摆好(关过渡),强制重排,再切到最终几何。
|
|
4452
|
+
* 于是"下移 + 展开"是一段连续动画,而不是旧的消失、新的出现。
|
|
4453
|
+
*/
|
|
4454
|
+
function openVCard() {
|
|
4455
|
+
if (vcardState.open || panelOpen) return
|
|
4456
|
+
if (!state.selection) return
|
|
4457
|
+
// ⚠️ 两条来源的定位依据不一样,不能只认 Range:
|
|
4458
|
+
// · 本文档里的选区 → 有 Range(几何 / 选中提示都按它算);
|
|
4459
|
+
// · 侧边栏网页(不透明源 iframe)→ 父页面**拿不到那个 Range**,只有桥换算好的
|
|
4460
|
+
// 视口矩形(state.selection.rect)和桥采好的上下文 —— 见 vcardGeometry / sendVCard。
|
|
4461
|
+
if (!state.selection.range && state.selection.source !== 'iframe') return
|
|
4462
|
+
// 页面没焦点 / 用户正在别处打字:不抢 —— 抢了就是把人家别处的听写拽过来
|
|
4463
|
+
if (!hasFocusEvidence(state.selection)) return
|
|
4464
|
+
if (isTypingTarget(document.activeElement)) return
|
|
4465
|
+
ensureVCard()
|
|
4466
|
+
// 上一轮的关机动画可能还没收尾(用户马上又划了新文字、又开麦):先收干净再开
|
|
4467
|
+
if (vcardState.closing || vcardState.crtTimer) finishCrtOff()
|
|
4468
|
+
vcardState.range = state.selection.range || null
|
|
4469
|
+
vcardState.anchorRect = state.selection.rect || null
|
|
4470
|
+
var geo = vcardGeometry()
|
|
4471
|
+
if (!geo) return
|
|
4472
|
+
var pillRect = button.getBoundingClientRect()
|
|
4473
|
+
vcardState.open = true
|
|
4474
|
+
vcard.style.transition = 'none'
|
|
4475
|
+
vcard.style.display = 'flex'
|
|
4476
|
+
// ── ① 先在**最终宽度**下量一次(同一帧内完成,用户看不见)──
|
|
4477
|
+
// 为什么必须在这个宽度下量:正文按宽度折行,在浮标那条窄缝(~86px)里量出来的行数会暴涨
|
|
4478
|
+
// (踩过:单行卡被顶到 111px)。量完之后形变全程不再读 scrollHeight。
|
|
4479
|
+
vcard.style.width = Math.round(geo.width) + 'px'
|
|
4480
|
+
void vcard.offsetWidth
|
|
4481
|
+
var finalBoxHeight = Math.max(24, Math.min(vcardBox.scrollHeight, VCARD_TEXT_MAX))
|
|
4482
|
+
var finalCardHeight = finalBoxHeight + VCARD_PAD_Y
|
|
4483
|
+
var finalBoxWidth = Math.round(vcardBox.getBoundingClientRect().width)
|
|
4484
|
+
// ── ② 形变期间把**正文宽度钉死** ──
|
|
4485
|
+
// 卡片变宽只是"揭开"正文,正文自己不再逐帧重新折行 —— 这是"下移展开"卡顿的主因
|
|
4486
|
+
// (每帧重排 textarea 里的文本)。钉死之后,卡片变宽只是一次裁剪,代价小得多。
|
|
4487
|
+
if (finalBoxWidth > 0) {
|
|
4488
|
+
vcardBox.style.flex = '0 0 auto'
|
|
4489
|
+
vcardBox.style.width = finalBoxWidth + 'px'
|
|
4490
|
+
}
|
|
4491
|
+
vcardBox.style.height = finalBoxHeight + 'px'
|
|
4492
|
+
vcardBox.style.overflowY = finalBoxHeight >= VCARD_TEXT_MAX ? 'auto' : 'hidden'
|
|
4493
|
+
// ── ③ 回到浮标那一格:位置 / 宽 / 高 / 圆角都从那里出发 ──
|
|
4494
|
+
vcard.style.width = Math.round(pillRect.width || 86) + 'px'
|
|
4495
|
+
vcard.style.left = Math.round(pillRect.left) + 'px'
|
|
4496
|
+
vcard.style.top = Math.round(pillRect.top) + 'px'
|
|
4497
|
+
vcard.style.height = Math.round(pillRect.height || 28) + 'px'
|
|
4498
|
+
vcard.style.borderRadius = '999px'
|
|
4499
|
+
markVCardListening(true)
|
|
4500
|
+
void vcard.offsetWidth
|
|
4501
|
+
// ── ④ 开过渡,走向终态 ──
|
|
4502
|
+
// 宽度这次**也参与**:上一版它不参与,第一帧就从 86px 跳到 460px(用户报的"跳变")。
|
|
4503
|
+
vcard.style.transition = ''
|
|
4504
|
+
vcard.style.borderRadius = ''
|
|
4505
|
+
vcard.style.left = Math.round(geo.left) + 'px'
|
|
4506
|
+
vcard.style.top = Math.round(geo.top) + 'px'
|
|
4507
|
+
vcard.style.width = Math.round(geo.width) + 'px'
|
|
4508
|
+
vcard.style.height = finalCardHeight + 'px'
|
|
4509
|
+
scheduleVCardUnpin()
|
|
4510
|
+
// 卡片打开后才画选中提示(选中/浮标阶段不画:那时原生底纹还在)
|
|
4511
|
+
ensureSelectionMarkers()
|
|
4512
|
+
paintSelectionMarkers(true)
|
|
4513
|
+
hideButton()
|
|
4514
|
+
// 卡片开着期间必须继续看麦克风:既要判断"这次录音结束了"(停掉呼吸),
|
|
4515
|
+
// 也要能接住"又按了一次 Fn"(重新标记在听)。幂等,正常路径上计时器本来就在跑。
|
|
4516
|
+
startMicWatch()
|
|
4517
|
+
// 关键一步:输入法上屏只认焦点元素,焦点必须在我们这个框里
|
|
4518
|
+
focusVCard()
|
|
4519
|
+
// 注意:焦点一转移,原生选区就被浏览器清掉了,于是**卡片开着时看不到选中底纹**。
|
|
4520
|
+
// 这是已知取舍:补底纹的两条路(CSS Custom Highlight / 自绘色块)都被否掉了 ——
|
|
4521
|
+
// 前者只有 Chromium 系支持,后者是盖在字上的色块。要恢复就从这里接回去。
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
/**
|
|
4525
|
+
* 「在听」标记与空框占位文案。
|
|
4526
|
+
*
|
|
4527
|
+
* 麦克风一松开就必须停掉呼吸 —— 否则卡片还在泛青呼吸,等于在说"我还在听",
|
|
4528
|
+
* 而这时输入法早就松手了。空框的占位也跟着换("正在听…" → "直接打字也行…"),
|
|
4529
|
+
* 不然那句话同样是在撒谎。
|
|
4530
|
+
*/
|
|
4531
|
+
function markVCardListening(on) {
|
|
4532
|
+
if (!vcard) return
|
|
4533
|
+
vcard.setAttribute('data-voiced', on ? '1' : '0')
|
|
4534
|
+
if (!vcardBox) return
|
|
4535
|
+
if (String(vcardBox.value || '').trim()) return
|
|
4536
|
+
vcardBox.placeholder = on ? '正在听…' : '直接打字也行…'
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
function focusVCard() {
|
|
4540
|
+
if (!vcardBox) return
|
|
4541
|
+
try {
|
|
4542
|
+
vcardBox.focus({ preventScroll: true })
|
|
4543
|
+
} catch (error) {
|
|
4544
|
+
try {
|
|
4545
|
+
vcardBox.focus()
|
|
4546
|
+
} catch (inner) {
|
|
4547
|
+
/* 桩环境没有 focus:不影响逻辑 */
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
/**
|
|
4553
|
+
* 收起卡片。
|
|
4554
|
+
*
|
|
4555
|
+
* **不还回「✦ 解读」浮标**(用户要求):取消就是取消 —— 浮标再冒出来,等于在问
|
|
4556
|
+
* "要不要解读",而用户刚表达的是"这次算了"。要解读就重新划一次。
|
|
4557
|
+
* 发送那条路也不还(它直接开面板了)。
|
|
4558
|
+
*/
|
|
4559
|
+
/** 选中提示的元素(懒建):下划线层 + 牵引线(SVG 贝塞尔)+ 锚点圆。 */
|
|
4560
|
+
function ensureSelectionMarkers() {
|
|
4561
|
+
if (selMark) return
|
|
4562
|
+
selMark = el('div', 'dsh-sel-umark')
|
|
4563
|
+
layer.appendChild(selMark)
|
|
4564
|
+
var NS = 'http://www.w3.org/2000/svg'
|
|
4565
|
+
selThread = document.createElementNS(NS, 'svg')
|
|
4566
|
+
selThread.setAttribute('class', 'dsh-sel-thread')
|
|
4567
|
+
selThreadPath = document.createElementNS(NS, 'path')
|
|
4568
|
+
selThreadPath.setAttribute('class', 'dsh-sel-threadpath')
|
|
4569
|
+
selThreadPath.setAttribute('fill', 'none')
|
|
4570
|
+
selThread.appendChild(selThreadPath)
|
|
4571
|
+
selThreadDot = document.createElementNS(NS, 'circle')
|
|
4572
|
+
selThreadDot.setAttribute('class', 'dsh-sel-threaddot')
|
|
4573
|
+
selThreadDot.setAttribute('r', '2.6')
|
|
4574
|
+
selThreadDot.setAttribute('stroke-width', '1.5')
|
|
4575
|
+
selThread.appendChild(selThreadDot)
|
|
4576
|
+
layer.appendChild(selThread)
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
/**
|
|
4580
|
+
* 画"选中提示":下划线(逐行矩形、两端渐隐)+ 从选区牵引到卡片的虚线。
|
|
4581
|
+
*
|
|
4582
|
+
* **只在卡片打开后才出现**(用户定的):选中/浮标阶段不画 —— 那时原生底纹还在,
|
|
4583
|
+
* 两者同时出现会互相打架。等底纹被抢焦点吃掉,这两样正好接上。
|
|
4584
|
+
*
|
|
4585
|
+
* 下划线按 range 的**逐行矩形**贴(多行选区就是多条),不是整块一条线。
|
|
4586
|
+
* 牵引线从选区**末行中点**竖直落到卡片上缘 —— 这一条会穿过下方文字(用户接受),
|
|
4587
|
+
* 所以它是 2px 宽的竖条 + repeating-gradient 虚线,而不是实线。
|
|
4588
|
+
*/
|
|
4589
|
+
function paintSelectionMarkers(grow) {
|
|
4590
|
+
if (!selMark) return
|
|
4591
|
+
clearSelectionMarkers()
|
|
4592
|
+
var range = vcardState.range
|
|
4593
|
+
if (!vcard || !vcardState.open) return
|
|
4594
|
+
if (!range && !vcardState.anchorRect) return
|
|
4595
|
+
/**
|
|
4596
|
+
* 侧边栏网页那条路(没有 Range):**只画牵引线,不画下划线**。
|
|
4597
|
+
*
|
|
4598
|
+
* 两个理由:① 逐行矩形在帧内,父页面拿不到(桥只报了一个锚点矩形);
|
|
4599
|
+
* ② 帧内原生底纹是否还在由浏览器说了算 —— 万一还在,我们按估算位置再画一条
|
|
4600
|
+
* 下划线就是"划两层"(本文档那条路当初踩过同一个坑)。
|
|
4601
|
+
* 牵引线是独立的一条线,不存在这个冲突,所以留着:卡片和那段文字之间得有可见的联系。
|
|
4602
|
+
*/
|
|
4603
|
+
var fromFrame = !range && !!vcardState.anchorRect
|
|
4604
|
+
var rects = fromFrame ? null : range.getClientRects ? range.getClientRects() : null
|
|
4605
|
+
if (!fromFrame && (!rects || !rects.length)) return
|
|
4606
|
+
var accent = 'var(--sel-a1)'
|
|
4607
|
+
// ⚠️ 必须先**按行归并**再画:
|
|
4608
|
+
// 一行里如果有行内元素(<code>、粗体、链接),getClientRects() 会给出**多个矩形**,
|
|
4609
|
+
// 而且它们的高度 / 底边各不相同 —— 按矩形各画一条就会"划两层",
|
|
4610
|
+
// 渐隐也会出现在行中间(用户实测的两个问题,同一个根因)。
|
|
4611
|
+
// ⚠️ 第一步:丢掉"容器矩形"。
|
|
4612
|
+
// 选区跨**整块**时,getClientRects() 会把块级盒子也返回 —— 一个覆盖整段的高矩形
|
|
4613
|
+
// (实测:一段两行会额外给出 h=53 的块盒,一个代码块给出 h=80 的块盒)。
|
|
4614
|
+
// 它会把段内所有行吞进一组,结果整段只画一条下划线、很多行没线(用户实测)。
|
|
4615
|
+
// 判据用高度中位数:行片段的高度都接近行高,块盒是它的数倍。
|
|
4616
|
+
// ⚠️ 这一整段只在有 `rects` 时跑:帧内那条根本没有逐行矩形(rects = null),
|
|
4617
|
+
// 它只画下面的牵引线。
|
|
4618
|
+
var lines = []
|
|
4619
|
+
if (!fromFrame) {
|
|
4620
|
+
var heights = []
|
|
4621
|
+
for (var h = 0; h < rects.length; h += 1) {
|
|
4622
|
+
var probe = rects[h]
|
|
4623
|
+
if (probe && probe.width) heights.push(probe.bottom - probe.top)
|
|
4624
|
+
}
|
|
4625
|
+
heights.sort(function (a, b) {
|
|
4626
|
+
return a - b
|
|
4627
|
+
})
|
|
4628
|
+
var median = heights.length ? heights[Math.floor(heights.length / 2)] : 0
|
|
4629
|
+
var maxLineHeight = median > 0 ? median * 1.8 : Infinity
|
|
4630
|
+
for (var i = 0; i < rects.length; i += 1) {
|
|
4631
|
+
var rect = rects[i]
|
|
4632
|
+
if (!rect || !rect.width) continue
|
|
4633
|
+
if (rect.bottom - rect.top > maxLineHeight) continue
|
|
4634
|
+
var height = rect.bottom - rect.top
|
|
4635
|
+
var center = rect.top + height / 2
|
|
4636
|
+
var line = null
|
|
4637
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
4638
|
+
var group = lines[j]
|
|
4639
|
+
var groupHeight = group.bottom - group.top
|
|
4640
|
+
var groupCenter = group.top + groupHeight / 2
|
|
4641
|
+
// ⚠️ 判"同一行"必须看**垂直中心**,不能看重叠面积:
|
|
4642
|
+
// 行内 code 的盒子可能比整行还高(行高 1.8 时尤其明显),
|
|
4643
|
+
// 按重叠判会把**下一行**也吞进同一组 → 那几行就没有下划线了(用户实测)。
|
|
4644
|
+
// 中心距的容差取"较矮者的一半":同一行的元素中心几乎重合,
|
|
4645
|
+
// 相邻行的中心差至少一个行高,两者区分得很干净。
|
|
4646
|
+
if (height > 0 && groupHeight > 0 && Math.abs(groupCenter - center) <= Math.min(groupHeight, height) * 0.5) {
|
|
4647
|
+
line = group
|
|
4648
|
+
break
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
if (line) {
|
|
4652
|
+
line.left = Math.min(line.left, rect.left)
|
|
4653
|
+
line.right = Math.max(line.right, rect.right)
|
|
4654
|
+
line.top = Math.min(line.top, rect.top)
|
|
4655
|
+
line.bottom = Math.max(line.bottom, rect.bottom)
|
|
4656
|
+
} else {
|
|
4657
|
+
lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom })
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
for (var k = 0; k < lines.length; k += 1) {
|
|
4662
|
+
var row = lines[k]
|
|
4663
|
+
var mark = el('div', 'dsh-sel-uline')
|
|
4664
|
+
mark.style.left = Math.round(row.left) + 'px'
|
|
4665
|
+
mark.style.top = Math.round(row.bottom + 3) + 'px'
|
|
4666
|
+
mark.style.width = Math.round(row.right - row.left) + 'px'
|
|
4667
|
+
// U3:两端渐隐 —— 归并之后就是"一行的两端",不会再出现在行中间
|
|
4668
|
+
mark.style.background =
|
|
4669
|
+
'linear-gradient(90deg,transparent,' + accent + ' 12%,' + accent + ' 88%,transparent)'
|
|
4670
|
+
selMark.appendChild(mark)
|
|
4671
|
+
}
|
|
4672
|
+
var first = fromFrame ? vcardState.anchorRect : lines[0]
|
|
4673
|
+
var last = fromFrame ? vcardState.anchorRect : lines[lines.length - 1]
|
|
4674
|
+
var geo = vcardGeometry()
|
|
4675
|
+
if (!first || !last || !geo) return
|
|
4676
|
+
// 终点用**算出来的最终位置**(geo.top),不是卡片此刻的 rect ——
|
|
4677
|
+
// 开卡片时卡片还在从"解读按钮"那一格形变过来,读当下的 rect 会先指到按钮上,
|
|
4678
|
+
// 等形变结束才跳过去(用户:"慢半拍""一开始不要连接到解读按钮上")。
|
|
4679
|
+
// 所以这里一律瞄准最终位置;形变期间不需要跟随(形变结束后那次重画只是安全网)。
|
|
4680
|
+
var cardTop = geo.top
|
|
4681
|
+
var above = cardTop < first.top
|
|
4682
|
+
var anchor = above ? first : last
|
|
4683
|
+
var sx = (anchor.left + anchor.right) / 2
|
|
4684
|
+
var sy = above ? anchor.top - 2 : anchor.bottom + 2
|
|
4685
|
+
// 终点:卡片上(下)缘,x 落在卡片宽度内、左右各留 24px;
|
|
4686
|
+
// **多探进卡片 4px** —— SVG 在卡片下层,多出来那段被卡片盖住,视觉上正好接住。
|
|
4687
|
+
var endY = above ? cardTop + (geo.height || 42) + 4 : cardTop + 4
|
|
4688
|
+
var endX = Math.max(geo.left + 24, Math.min(sx, geo.left + geo.width - 24))
|
|
4689
|
+
// 可见的贝塞尔:两端各一个反向的横向控制量 → S 形(原来控制点和端点同 x,是直线)
|
|
4690
|
+
var dy = Math.abs(endY - sy) || 1
|
|
4691
|
+
var bend = 18
|
|
4692
|
+
selThreadPath.setAttribute(
|
|
4693
|
+
'd',
|
|
4694
|
+
'M' + sx + ' ' + sy + ' C ' + (sx + bend) + ' ' + (sy + dy * 0.45) + ', ' +
|
|
4695
|
+
(endX - bend) + ' ' + (endY - dy * 0.45) + ', ' + endX + ' ' + endY,
|
|
4696
|
+
)
|
|
4697
|
+
selThreadPath.setAttribute('stroke', accent)
|
|
4698
|
+
selThreadDot.setAttribute('cx', sx)
|
|
4699
|
+
selThreadDot.setAttribute('cy', sy)
|
|
4700
|
+
selThreadDot.setAttribute('fill', accent)
|
|
4701
|
+
selThreadDot.setAttribute('stroke', 'var(--dsw-alias-bg-base,#fff)')
|
|
4702
|
+
// 生长动画**只在开卡片时**播:滚动 / 缩放也会走到这里,而 display:none → block
|
|
4703
|
+
// 会重启动画 —— 那就是又一次"闪一下"(同一个坑,别踩第二遍)。
|
|
4704
|
+
if (grow) selThread.setAttribute('data-grow', '1')
|
|
4705
|
+
else selThread.removeAttribute('data-grow')
|
|
4706
|
+
selThread.style.display = 'block'
|
|
4707
|
+
selThread.style.opacity = '1'
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
/** 收起选中提示(幂等)。 */
|
|
4711
|
+
function clearSelectionMarkers() {
|
|
4712
|
+
if (selMark && selMark.children.length) selMark.textContent = ''
|
|
4713
|
+
if (selMark) selMark.style.opacity = ''
|
|
4714
|
+
if (selThread) {
|
|
4715
|
+
selThread.style.display = 'none'
|
|
4716
|
+
selThread.style.opacity = ''
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
/** 关机动画那条亮线(懒建)。 */
|
|
4721
|
+
function ensureCrtLine() {
|
|
4722
|
+
if (crtLine) return crtLine
|
|
4723
|
+
crtLine = el('div', 'dsh-sel-crtline')
|
|
4724
|
+
layer.appendChild(crtLine)
|
|
4725
|
+
return crtLine
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4728
|
+
/**
|
|
4729
|
+
* 亮线的分段与火花(懒建,只建一次)。
|
|
4730
|
+
*
|
|
4731
|
+
* 5 段首尾相接拼成整条线(看上去就是一条实线);收缩时各段延迟不同,
|
|
4732
|
+
* 于是"往中间收"和"错位露缝"同时发生 —— 这点像素故障是刻意做的。
|
|
4733
|
+
* 末尾两个火花是段子死掉之后还在原位闪两下的残留像素。
|
|
4734
|
+
*/
|
|
4735
|
+
function ensureCrtSegments(line) {
|
|
4736
|
+
if (line.children && line.children.length) return
|
|
4737
|
+
for (var i = 0; i < 5; i += 1) line.appendChild(el('div', 'dsh-sel-crtseg'))
|
|
4738
|
+
line.appendChild(el('div', 'dsh-sel-crtspark'))
|
|
4739
|
+
line.appendChild(el('div', 'dsh-sel-crtspark'))
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
/** 系统是否要求减少动效。 */
|
|
4743
|
+
function prefersReducedMotion() {
|
|
4744
|
+
try {
|
|
4745
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
4746
|
+
} catch (error) {
|
|
4747
|
+
return false
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
/**
|
|
4752
|
+
* 播"老式电视机关机":卡片塌成一条线,亮线再横缩成一个点(A 方案,420ms)。
|
|
4753
|
+
*
|
|
4754
|
+
* 只在**取消**时播 —— 发送那条要立刻开面板,动画会和面板叠在一起。
|
|
4755
|
+
*
|
|
4756
|
+
* 亮线是**独立元素**,不是卡片的 box-shadow:box-shadow 会跟着 transform 一起被压扁,
|
|
4757
|
+
* 塌到 5% 时连光晕都成一条(实测踩过)。颜色也按明暗给两套 ——
|
|
4758
|
+
* 浅色底上白线看不见(实测只差 ~15/255),深色底上反过来,白线才亮得起来。
|
|
4759
|
+
*
|
|
4760
|
+
* 返回 false 表示没播(系统要求减少动效 / 卡片量不出尺寸),调用方直接收掉。
|
|
4761
|
+
*/
|
|
4762
|
+
function playCrtOff() {
|
|
4763
|
+
if (!vcard || prefersReducedMotion()) return false
|
|
4764
|
+
var rect = vcard.getBoundingClientRect()
|
|
4765
|
+
if (!rect.width || !rect.height) return false
|
|
4766
|
+
var line = ensureCrtLine()
|
|
4767
|
+
var dark = false
|
|
4768
|
+
try {
|
|
4769
|
+
dark = isDarkSurface(document.body)
|
|
4770
|
+
} catch (error) {
|
|
4771
|
+
dark = false
|
|
4772
|
+
}
|
|
4773
|
+
// 亮线摆在卡片正中、宽度跟着卡片(都是 fixed 定位,同一套视口坐标)
|
|
4774
|
+
line.style.left = Math.round(rect.left) + 'px'
|
|
4775
|
+
line.style.top = Math.round(rect.top + rect.height / 2 - 1.5) + 'px'
|
|
4776
|
+
line.style.width = Math.round(rect.width) + 'px'
|
|
4777
|
+
ensureCrtSegments(line)
|
|
4778
|
+
// 颜色挂在容器上,分段/火花用 currentColor 继承 —— 一处赋值,7 个子元素跟着变
|
|
4779
|
+
line.style.color = dark ? '#FFFFFF' : 'var(--sel-a1)'
|
|
4780
|
+
line.style.background = 'transparent'
|
|
4781
|
+
line.style.boxShadow = 'none'
|
|
4782
|
+
line.style.filter = dark
|
|
4783
|
+
? 'drop-shadow(0 0 6px rgba(190,245,255,.8))'
|
|
4784
|
+
: 'drop-shadow(0 0 6px rgba(13,148,136,.55))'
|
|
4785
|
+
vcardState.closing = true
|
|
4786
|
+
// 选中提示跟着卡片一起淡掉(它俩是"卡片这一段"的注解,卡片塌了就不该还挂着)
|
|
4787
|
+
if (selMark) selMark.style.opacity = '0'
|
|
4788
|
+
if (selThread) selThread.style.opacity = '0'
|
|
4789
|
+
if (selThreadDot) selThreadDot.style.opacity = '0'
|
|
4790
|
+
// 摘掉再挂上:让动画能重播(同一元素上连续两次收卡片)
|
|
4791
|
+
vcard.removeAttribute('data-off')
|
|
4792
|
+
line.removeAttribute('data-off')
|
|
4793
|
+
void vcard.offsetWidth
|
|
4794
|
+
vcard.setAttribute('data-off', 'a')
|
|
4795
|
+
line.setAttribute('data-off', 'a')
|
|
4796
|
+
var done = function () {
|
|
4797
|
+
if (vcardState.crtTimer) {
|
|
4798
|
+
clearTimeout(vcardState.crtTimer)
|
|
4799
|
+
vcardState.crtTimer = null
|
|
4800
|
+
}
|
|
4801
|
+
if (vcard && vcardState.crtHandler) {
|
|
4802
|
+
vcard.removeEventListener('animationend', vcardState.crtHandler)
|
|
4803
|
+
vcardState.crtHandler = null
|
|
4804
|
+
}
|
|
4805
|
+
finishCrtOff()
|
|
4806
|
+
}
|
|
4807
|
+
// animationend 是主路径;另挂兜底 —— 标签页在后台时动画根本不跑,事件也就不会来
|
|
4808
|
+
vcardState.crtHandler = function (event) {
|
|
4809
|
+
if (event.target === vcard) done()
|
|
4810
|
+
}
|
|
4811
|
+
vcard.addEventListener('animationend', vcardState.crtHandler)
|
|
4812
|
+
vcardState.crtTimer = later(done, 700)
|
|
4813
|
+
return true
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
/**
|
|
4817
|
+
* 关机动画收尾:真正把卡片和亮线藏掉。
|
|
4818
|
+
*
|
|
4819
|
+
* 幂等 —— 不播动画时 closeVCard 直接调它,顺便清掉上一轮动画的残留。
|
|
4820
|
+
*/
|
|
4821
|
+
function finishCrtOff() {
|
|
4822
|
+
vcardState.closing = false
|
|
4823
|
+
if (vcardState.crtTimer) {
|
|
4824
|
+
clearTimeout(vcardState.crtTimer)
|
|
4825
|
+
vcardState.crtTimer = null
|
|
4826
|
+
}
|
|
4827
|
+
if (vcard && vcardState.crtHandler) {
|
|
4828
|
+
vcard.removeEventListener('animationend', vcardState.crtHandler)
|
|
4829
|
+
vcardState.crtHandler = null
|
|
4830
|
+
}
|
|
4831
|
+
if (vcard) {
|
|
4832
|
+
vcard.removeAttribute('data-off')
|
|
4833
|
+
vcard.style.display = 'none'
|
|
4834
|
+
vcard.style.opacity = ''
|
|
4835
|
+
vcard.style.filter = ''
|
|
4836
|
+
vcard.style.transform = ''
|
|
4837
|
+
vcard.style.borderRadius = ''
|
|
4838
|
+
vcard.setAttribute('data-voiced', '0')
|
|
4839
|
+
vcard.setAttribute('data-text', '0')
|
|
4840
|
+
}
|
|
4841
|
+
if (vcardBox) {
|
|
4842
|
+
vcardBox.value = ''
|
|
4843
|
+
vcardBox.style.height = ''
|
|
4844
|
+
vcardBox.style.overflowY = 'hidden'
|
|
4845
|
+
}
|
|
4846
|
+
if (crtLine) {
|
|
4847
|
+
crtLine.removeAttribute('data-off')
|
|
4848
|
+
crtLine.style.opacity = ''
|
|
4849
|
+
crtLine.style.filter = ''
|
|
4850
|
+
}
|
|
4851
|
+
clearSelectionMarkers()
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4854
|
+
/**
|
|
4855
|
+
* 收起卡片。
|
|
4856
|
+
*
|
|
4857
|
+
* animate=true(取消:Esc / 点空白)时先播关机动画,动画结束再真正隐藏;
|
|
4858
|
+
* 其余路径(发送 / 调试钩子)立刻收掉。
|
|
4859
|
+
* **不还回「✦ 解读」浮标**:取消就是取消,要解读就重新划一次。
|
|
4860
|
+
*/
|
|
4861
|
+
/** 停掉"等文字定稿"的观察(幂等)。 */
|
|
4862
|
+
function stopVoiceSettle() {
|
|
4863
|
+
if (vcardState.settleTimer) {
|
|
4864
|
+
clearInterval(vcardState.settleTimer)
|
|
4865
|
+
vcardState.settleTimer = null
|
|
4866
|
+
}
|
|
4867
|
+
if (vcardState.settleComposeTimer) {
|
|
4868
|
+
clearTimeout(vcardState.settleComposeTimer)
|
|
4869
|
+
vcardState.settleComposeTimer = null
|
|
4870
|
+
}
|
|
4871
|
+
// ⚠️ 这里**不**上报测量:停表既发生在"正常决定之前"(那一拍马上就要 finishVoiceSettle),
|
|
4872
|
+
// 也发生在"被提前打断"时。在这里上报会把决策一律记成 stopped(踩过)。
|
|
4873
|
+
// 提前打断的场景由调用方各自上报(closeVCard / 新录音)。
|
|
4874
|
+
vcardState.settleLastText = ''
|
|
4875
|
+
vcardState.settleStableAt = 0
|
|
4876
|
+
vcardState.settleDeadline = 0
|
|
4877
|
+
}
|
|
4878
|
+
|
|
4879
|
+
/** 文字定稿后,才真正执行收尾(发送 / 取消 / 什么都不做)。 */
|
|
4880
|
+
function finishVoiceSettle(text) {
|
|
4881
|
+
if (!vcardState.open) return
|
|
4882
|
+
var spoken = String(text || '').trim()
|
|
4883
|
+
if (spoken && settingsLive.voiceAutoSend !== false) {
|
|
4884
|
+
sendVCard()
|
|
4885
|
+
return
|
|
4886
|
+
}
|
|
4887
|
+
if (!spoken && settingsLive.voiceCancelOnSilence !== false) {
|
|
4888
|
+
closeVCard(true)
|
|
4889
|
+
return
|
|
4890
|
+
}
|
|
4891
|
+
markVCardListening(false)
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
/**
|
|
4895
|
+
* 松手之后,等文字定稿再决定收尾动作。
|
|
4896
|
+
*
|
|
4897
|
+
* 判据是"连续 VOICE_SETTLE_MS 没有变化",最长等 VOICE_SETTLE_MAX_MS。
|
|
4898
|
+
* 豆包的智能整理通常会在这段窗口里把内容改写一到两次;每改一次就重新计时。
|
|
4899
|
+
*/
|
|
4900
|
+
function startVoiceSettle() {
|
|
4901
|
+
stopVoiceSettle()
|
|
4902
|
+
vcardState.settleLastText = String((vcardBox && vcardBox.value) || '')
|
|
4903
|
+
vcardState.settleStableAt = Date.now()
|
|
4904
|
+
vcardState.settleDeadline = Date.now() + VOICE_SETTLE_MAX_MS
|
|
4905
|
+
vcardState.settleTimer = setInterval(function () {
|
|
4906
|
+
if (!vcardState.open) {
|
|
4907
|
+
stopVoiceSettle()
|
|
4908
|
+
return
|
|
4909
|
+
}
|
|
4910
|
+
var now = Date.now()
|
|
4911
|
+
var text = String((vcardBox && vcardBox.value) || '')
|
|
4912
|
+
if (text !== vcardState.settleLastText) {
|
|
4913
|
+
// 还在改写(智能整理)→ 重新计时,继续等
|
|
4914
|
+
vcardState.settleLastText = text
|
|
4915
|
+
vcardState.settleStableAt = now
|
|
4916
|
+
return
|
|
4917
|
+
}
|
|
4918
|
+
if (now - vcardState.settleStableAt < VOICE_SETTLE_MS && now < vcardState.settleDeadline) return
|
|
4919
|
+
stopVoiceSettle()
|
|
4920
|
+
finishVoiceSettle(text)
|
|
4921
|
+
}, VOICE_SETTLE_POLL_MS)
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
function closeVCard(animate) {
|
|
4925
|
+
if (!vcardState.open) return
|
|
4926
|
+
vcardState.open = false
|
|
4927
|
+
vcardState.range = null
|
|
4928
|
+
vcardState.anchorRect = null
|
|
4929
|
+
// ⚠️ 必须**显式 blur**,不能只把卡片 display:none 掉。
|
|
4930
|
+
//
|
|
4931
|
+
// 输入法(豆包)的输入上下文是绑在"当前那个文本输入框"上的:卡片打开时我们把焦点
|
|
4932
|
+
// 抢给它,上下文就绑在了卡片的 textarea 上;卡片只是被隐藏时,它**仍然是输入法的输入框**
|
|
4933
|
+
// (display:none 不会让 macOS 的输入法自己解绑)。于是之后焦点虽然转到小窗输入框,
|
|
4934
|
+
// 输入法的合成仍然发生在卡片上 —— 用户实测的症状就是:
|
|
4935
|
+
// 小窗输入框能收到 keydown,却**永远收不到 beforeinput**(输入法合成),
|
|
4936
|
+
// 打不进字、也没有插入符。
|
|
4937
|
+
// focus-trace 抓到的实测序列:
|
|
4938
|
+
// 临时卡片上有 beforeinput(正常)→ 小窗输入框上只有 keydown(打不进)
|
|
4939
|
+
// → 焦点回到宿主输入框后 beforeinput 立刻恢复。
|
|
4940
|
+
if (vcardBox && typeof vcardBox.blur === 'function') {
|
|
4941
|
+
try {
|
|
4942
|
+
vcardBox.blur()
|
|
4943
|
+
} catch (error) {
|
|
4944
|
+
/* 桩环境 */
|
|
4945
|
+
}
|
|
4946
|
+
}
|
|
4947
|
+
stopVoiceSettle()
|
|
4948
|
+
stopMicWatch()
|
|
4949
|
+
// 形变还没结束就被关掉时,正文宽度可能还钉着 —— 一并解开
|
|
4950
|
+
unpinVCardBox()
|
|
4951
|
+
// 动画期间正文/占位都留着(要塌的就是它),收尾时才清
|
|
4952
|
+
if (animate && playCrtOff()) return
|
|
4953
|
+
finishCrtOff()
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
/** 发送:跳过翻译与详解,把说的话当作第一条追问直接发出去。 */
|
|
4957
|
+
function sendVCard() {
|
|
4958
|
+
var asked = String((vcardBox && vcardBox.value) || '').trim()
|
|
4959
|
+
if (!asked) {
|
|
4960
|
+
focusVCard()
|
|
4961
|
+
return
|
|
4962
|
+
}
|
|
4963
|
+
var selection = state.selection
|
|
4964
|
+
if (!selection) {
|
|
4965
|
+
closeVCard()
|
|
4966
|
+
return
|
|
4967
|
+
}
|
|
4968
|
+
// 侧边栏网页那条路没有 Range(不透明源帧的选区拿不出来),上下文 / 标签直接用
|
|
4969
|
+
// 帧内桥采好带过来的那份 —— 与 openForSelection 的 iframe 分支同一条规则。
|
|
4970
|
+
var contextInfo =
|
|
4971
|
+
selection.source === 'iframe' || !selection.range
|
|
4972
|
+
? {
|
|
4973
|
+
context: String(selection.context || ''),
|
|
4974
|
+
keyContext: String(selection.keyContext || ''),
|
|
4975
|
+
label: selection.label,
|
|
4976
|
+
}
|
|
4977
|
+
: collectContext(rangeSelection(selection.range))
|
|
4978
|
+
// 交接起点:卡片**现在**的矩形。必须在 closeVCard 之前量 —— 之后它 display:none,量出来是 0。
|
|
4979
|
+
// 小窗会从这一格"长出来"(见 growPanelFromCard)。
|
|
4980
|
+
vcardHandoff = vcardRect()
|
|
4981
|
+
closeVCard()
|
|
4982
|
+
openPanelWith(
|
|
4983
|
+
selection.text,
|
|
4984
|
+
contextInfo.context,
|
|
4985
|
+
contextInfo.label,
|
|
4986
|
+
selection.rect,
|
|
4987
|
+
contextInfo.keyContext,
|
|
4988
|
+
{ askFirst: asked },
|
|
4989
|
+
)
|
|
4990
|
+
clearDocSelection()
|
|
4991
|
+
// 选区连同它的记录一起作废(与 openForSelection 一致)
|
|
4992
|
+
state.selection = null
|
|
4993
|
+
state.bridgeActive = false
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
/**
|
|
4997
|
+
* "现在这一下该不该抢焦点"。
|
|
4998
|
+
*
|
|
4999
|
+
* 本文档里的选区:`document.hasFocus()` 就是答案(与改动前完全一致)。
|
|
5000
|
+
* 侧边栏网页里的选区:焦点其实落在**不透明源子框架**里,父文档的 `hasFocus()`
|
|
5001
|
+
* 有没有为真由浏览器实现说了算(跨引擎没有统一答案),所以对它改用
|
|
5002
|
+
* "这一帧刚报过动作"当证据 —— 桥报上来的那一刻,用户确实正在那个网页里划词 / 点按。
|
|
5003
|
+
*/
|
|
5004
|
+
function hasFocusEvidence(selection) {
|
|
5005
|
+
if (typeof document.hasFocus !== 'function') return true
|
|
5006
|
+
if (document.hasFocus()) return true
|
|
5007
|
+
if (!selection || selection.source !== 'iframe' || !selection.frame) return false
|
|
5008
|
+
return Date.now() - (frameTouchedAt.get(selection.frame) || 0) < FRAME_FOCUS_EVIDENCE_MS
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
/** 卡片当前的视口矩形(拿不到就返回 null —— 那就不做交接动画,老实瞬开)。 */
|
|
5012
|
+
function vcardRect() {
|
|
5013
|
+
if (!vcard || typeof vcard.getBoundingClientRect !== 'function') return null
|
|
5014
|
+
try {
|
|
5015
|
+
var rect = vcard.getBoundingClientRect()
|
|
5016
|
+
return rect && rect.width && rect.height ? rect : null
|
|
5017
|
+
} catch (error) {
|
|
5018
|
+
return null
|
|
5019
|
+
}
|
|
5020
|
+
}
|
|
5021
|
+
|
|
5022
|
+
/** host 报的占用者里,有没有已知输入法。 */
|
|
5023
|
+
function matchingIme(data) {
|
|
5024
|
+
if (!data || !data.capturing || !Array.isArray(data.processes)) return null
|
|
5025
|
+
for (var i = 0; i < data.processes.length; i++) {
|
|
5026
|
+
var proc = data.processes[i] || {}
|
|
5027
|
+
var id = String(proc.bundleId || '')
|
|
5028
|
+
for (var j = 0; j < IME_BUNDLES.length; j++) {
|
|
5029
|
+
if (id.indexOf(IME_BUNDLES[j]) === 0) return proc
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
5032
|
+
return null
|
|
5033
|
+
}
|
|
5034
|
+
|
|
5035
|
+
/**
|
|
5036
|
+
* 只在"有选区"时轮询(浮标亮着的那几拍)。不叠请求:上一拍没回来就跳过这一拍。
|
|
5037
|
+
* 拿不到 host 的能力(非 macOS / 探针没编出来)就**停掉轮询**,不再空转。
|
|
5038
|
+
*/
|
|
5039
|
+
function startMicWatch() {
|
|
5040
|
+
if (vcardState.timer) return
|
|
5041
|
+
// 两条路都要盯麦克风:解读选区(面板没开 → 语音卡)与引用选区(面板开着 → 自动引用)
|
|
5042
|
+
if (!state.selection && !state.quoteSelection) return
|
|
5043
|
+
vcardState.timer = setInterval(micTick, MIC_POLL_MS)
|
|
5044
|
+
timers.push(vcardState.timer)
|
|
5045
|
+
micTick()
|
|
5046
|
+
}
|
|
5047
|
+
|
|
5048
|
+
function stopMicWatch() {
|
|
5049
|
+
// ⚠️ 抑制标记必须**跟着轮询一起复位**,不能只放在"轮询到没在录"那一拍上:
|
|
5050
|
+
// 自动引用做完就会 hideQuoteButton → 停轮询(浮标收了就没什么可等的),
|
|
5051
|
+
// 那一拍于是可能永远不来,标记就一直挂着 true。
|
|
5052
|
+
//
|
|
5053
|
+
// 靠"下一次划词自愈"也不可靠:只有浮标亮起来那一刻麦克风**已经关着**,
|
|
5054
|
+
// 最早那一拍才会答"没在录"、顺手把它复位;麦克风正开着时每一拍都答"在录",
|
|
5055
|
+
// 标记就没机会复位。真实用法里这个顺序几乎凑不出来(鼠标一划,语音就断了),
|
|
5056
|
+
// 所以它更像颗埋着的雷而不是现行 bug —— 但状态机不该建立在
|
|
5057
|
+
// "用户凑不出这个顺序"上(将来若给这条路加"说完直接发送",它就立刻变成真问题)。
|
|
5058
|
+
//
|
|
5059
|
+
// 语义上这样才对:一次观察窗口(浮标亮着的这段时间)里只处理第一次开麦。
|
|
5060
|
+
quoteVoiceHandled = false
|
|
5061
|
+
if (!vcardState.timer) return
|
|
5062
|
+
clearInterval(vcardState.timer)
|
|
5063
|
+
vcardState.timer = null
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
function micTick() {
|
|
5067
|
+
if (vcardState.busy) return
|
|
5068
|
+
// 两个都空了(选区没了 / 引用浮标收了):没什么可等的,停掉轮询
|
|
5069
|
+
if (!state.selection && !state.quoteSelection) {
|
|
5070
|
+
stopMicWatch()
|
|
5071
|
+
return
|
|
5072
|
+
}
|
|
5073
|
+
vcardState.busy = true
|
|
5074
|
+
fetch(MIC_API)
|
|
5075
|
+
.then(function (response) {
|
|
5076
|
+
return response.json()
|
|
5077
|
+
})
|
|
5078
|
+
.then(function (data) {
|
|
5079
|
+
vcardState.busy = false
|
|
5080
|
+
if (!data || !data.available) {
|
|
5081
|
+
stopMicWatch()
|
|
5082
|
+
return
|
|
5083
|
+
}
|
|
5084
|
+
if (matchingIme(data)) {
|
|
5085
|
+
// ── 小窗开着:引用那条路(开麦 = 点一下「❝ 引用」+ 光标进小窗输入框)──
|
|
5086
|
+
// 与语音卡互斥:panelOpen 时前者根本不该启动(openVCard 自己也会拒)。
|
|
5087
|
+
if (panelOpen && state.quoteSelection && !vcardState.open) {
|
|
5088
|
+
if (quoteVoiceHandled) return
|
|
5089
|
+
quoteVoiceHandled = true
|
|
5090
|
+
autoQuoteFromVoice()
|
|
5091
|
+
return
|
|
5092
|
+
}
|
|
5093
|
+
if (vcardState.suppress) return
|
|
5094
|
+
// 又开始录了:上一次的"等定稿"作废
|
|
5095
|
+
stopVoiceSettle()
|
|
5096
|
+
if (!vcardState.open) openVCard()
|
|
5097
|
+
else markVCardListening(true)
|
|
5098
|
+
return
|
|
5099
|
+
}
|
|
5100
|
+
// 没人在录了:这一次录音结束,解除抑制(下一次开麦才允许再弹 / 再加引用)
|
|
5101
|
+
vcardState.suppress = false
|
|
5102
|
+
quoteVoiceHandled = false
|
|
5103
|
+
if (!vcardState.open) return
|
|
5104
|
+
// 开麦结束了。卡片**不立刻退出**:下面两个收尾行为都要等文字定稿
|
|
5105
|
+
//(松手那一刻输入法还会做一次智能整理,见 startVoiceSettle)。
|
|
5106
|
+
// 两个开关都关掉时卡片就留着 —— 一个字都没说也能接着手动打字。
|
|
5107
|
+
// 这里只把"在听"停掉(见 markVCardListening)。
|
|
5108
|
+
//
|
|
5109
|
+
// 两个收尾行为(设置页「界面 · 语音输入」,**默认都开**,见 Config 注释):
|
|
5110
|
+
// · voiceAutoSend:有字 → 等于替用户点了发送键(发送自己会关卡片、开小窗);
|
|
5111
|
+
// · voiceCancelOnSilence:没字 → 等于替用户按了 Esc(带关机动画)。
|
|
5112
|
+
// 都从 settingsLive 现读,所以设置一改下一次松手就生效;判断用 `!== false` ——
|
|
5113
|
+
// 还没加载完 / 读失败时按**出厂默认(开)**处理,与 host 侧 Config 的缺省值一致。
|
|
5114
|
+
// ⚠️ 松手这一刻**文字还没定稿**:豆包紧接着还会做一次智能整理,会改写输入框内容。
|
|
5115
|
+
// 所以两个收尾行为都不在这里执行,而是先挂上"等定稿"的观察(见 startVoiceSettle)。
|
|
5116
|
+
markVCardListening(false)
|
|
5117
|
+
// ⚠️ 这个分支在"麦克风关着"期间**每一拍都会走**(200ms 一次),
|
|
5118
|
+
// 所以必须幂等:已经在等定稿就不要再 startVoiceSettle() ——
|
|
5119
|
+
// 那会把"稳定起点"重置,永远等不到 VOICE_SETTLE_MS(实测就是这个坑:
|
|
5120
|
+
// settleFor 一直停在几十毫秒,卡片永远不动作)。
|
|
5121
|
+
if (
|
|
5122
|
+
!vcardState.settleTimer &&
|
|
5123
|
+
(settingsLive.voiceAutoSend !== false || settingsLive.voiceCancelOnSilence !== false)
|
|
5124
|
+
) {
|
|
5125
|
+
startVoiceSettle()
|
|
5126
|
+
}
|
|
5127
|
+
})
|
|
5128
|
+
.catch(function () {
|
|
5129
|
+
vcardState.busy = false
|
|
5130
|
+
stopMicWatch()
|
|
5131
|
+
})
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5134
|
+
/**
|
|
5135
|
+
* 开麦 = 点一下「❝ 引用」(用户 2026-10-09 定的行为)。
|
|
5136
|
+
*
|
|
5137
|
+
* 小窗开着时,划词浮出的是引用浮标;这时用户按下输入法的语音键,我们做的正是
|
|
5138
|
+
* "点一下那个浮标"那件事:把选中的文字挂进小窗、并把光标移到小窗输入框 ——
|
|
5139
|
+
* 于是输入法上屏的字直接落进小窗,和引用一起成为下一条追问。
|
|
5140
|
+
*
|
|
5141
|
+
* 为什么必须在**开麦那一刻**做:输入法上屏发生在松手那一刻,焦点得提前挪好
|
|
5142
|
+
* (与语音卡抢焦点同一条时序,见文件上方那段注释)。检测延迟由"探针 125ms +
|
|
5143
|
+
* 页面轮询 200ms"构成,豆包从按下到松手通常有几秒,够用。
|
|
5144
|
+
*
|
|
5145
|
+
* 守卫与 openVCard 完全一致(页面没焦点 / 用户正在别处打字 → 不抢,否则就是把
|
|
5146
|
+
* 人家在别处的听写拽进小窗),另外多加一条:插件自己的 🎤 正在录音时不抢 ——
|
|
5147
|
+
* 那套走 host 识别、与输入法语音是两回事,焦点该留给它。
|
|
5148
|
+
* ("没焦点"同样按 hasFocusEvidence 判:侧边栏网页里那条选区也得能走这条路。)
|
|
5149
|
+
*
|
|
5150
|
+
* 用户取消语音(Esc)或一个字没说:**引用留着**(可一键 ✕ 删)。撤销会让"引用到底
|
|
5151
|
+
* 加没加"变得难预期,而且点浮标那条路也是加了就加了。
|
|
5152
|
+
*/
|
|
5153
|
+
function autoQuoteFromVoice() {
|
|
5154
|
+
var selection = state.quoteSelection
|
|
5155
|
+
if (!panelOpen || !selection) return
|
|
5156
|
+
if (!hasFocusEvidence(selection)) return
|
|
5157
|
+
if (isTypingTarget(document.activeElement)) return
|
|
5158
|
+
if (voice && voice.phase !== 'idle') return
|
|
5159
|
+
// 与点浮标同一条路:上下文在这一刻才采("引用当时在哪一段对话里"最有用的就是它)
|
|
5160
|
+
addQuote(selection.text, selection.label, quoteContextFor(selection))
|
|
5161
|
+
hideQuoteButton()
|
|
5162
|
+
// addQuote 在"重复引用 / 到上限"时会提前 return(那两条路不会聚焦),但"说话的字要落进
|
|
5163
|
+
// 小窗"这件事仍然必须成立 —— 所以焦点在这里再兜一次(composerVisible 已由 addQuote 保证)。
|
|
5164
|
+
if (composerVisible()) focusAskBox()
|
|
3766
5165
|
}
|
|
3767
5166
|
|
|
3768
5167
|
function hideQuoteButton() {
|
|
3769
5168
|
quoteButton.style.display = 'none'
|
|
3770
5169
|
setQuotePop(false)
|
|
5170
|
+
// 浮标收了就没什么可等的了(与 hideButton 同一条规则);语音卡开着时不能停
|
|
5171
|
+
if (!vcardState.open) stopMicWatch()
|
|
3771
5172
|
}
|
|
3772
5173
|
|
|
3773
5174
|
// ────────────────────── 引用(❝):把别处的文字挂进输入框 ──────────────────────
|
|
@@ -4063,6 +5464,14 @@ window.__ModuleLoader__.load({
|
|
|
4063
5464
|
* 用 Map 而不是数组:既要按帧查自己的 url,也要遍历找回消息来源对应的帧。
|
|
4064
5465
|
*/
|
|
4065
5466
|
var bridged = new Map()
|
|
5467
|
+
/**
|
|
5468
|
+
* 帧 → 最后一次收到它的消息的时间戳。
|
|
5469
|
+
*
|
|
5470
|
+
* 只为一件事:判断"用户此刻是不是就在那个侧边栏网页里"(见 hasFocusEvidence)。
|
|
5471
|
+
* 焦点落在不透明源子框架里时,父文档的 `document.hasFocus()` 未必为真,
|
|
5472
|
+
* 而桥报上来的每条消息本身就是"用户刚在那儿动过"的证据。
|
|
5473
|
+
*/
|
|
5474
|
+
var frameTouchedAt = new Map()
|
|
4066
5475
|
/** 总开关:host 的 bridgeSidebarPreview 说关就关(见下面 ping 分支)。 */
|
|
4067
5476
|
var bridgeOn = true
|
|
4068
5477
|
/** 扫描节流用的定时器 id。 */
|
|
@@ -4242,11 +5651,14 @@ window.__ModuleLoader__.load({
|
|
|
4242
5651
|
}
|
|
4243
5652
|
// 帧没了就不会再有 clear 消息了:把它占着的那条选区一起清掉,
|
|
4244
5653
|
// 否则 `bridgeActive` 会一直挂着(表现为顶层选区塌掉也不收浮标)。
|
|
5654
|
+
// 卡片开着时也不清:帧没了但它占着的选区仍是"这一轮的解读对象"(发送要用),
|
|
5655
|
+
// 与下面 clear 分支同一条规则。
|
|
4245
5656
|
if (state.selection && state.selection.frame === dead[i]) {
|
|
4246
|
-
state.selection = null
|
|
4247
5657
|
state.bridgeActive = false
|
|
5658
|
+
if (!vcardState.open) state.selection = null
|
|
4248
5659
|
hideButton()
|
|
4249
5660
|
}
|
|
5661
|
+
frameTouchedAt.delete(dead[i])
|
|
4250
5662
|
bridged.delete(dead[i])
|
|
4251
5663
|
}
|
|
4252
5664
|
}
|
|
@@ -4283,6 +5695,8 @@ window.__ModuleLoader__.load({
|
|
|
4283
5695
|
if (!bridgeOn || !data || data.__dshSel !== 1) return
|
|
4284
5696
|
var frame = frameBySource(event.source)
|
|
4285
5697
|
if (!frame) return
|
|
5698
|
+
// 这条消息本身就是"用户此刻正在那个网页里"的证据(见 hasFocusEvidence)
|
|
5699
|
+
frameTouchedAt.set(frame, Date.now())
|
|
4286
5700
|
if (data.kind === 'press') {
|
|
4287
5701
|
// 帧内按下:与主会话一致——先收浮标。父页面收不到帧内的 mousedown,
|
|
4288
5702
|
// 少了这一条,在网页里点一下(取消选区、点别处、点另一个网页)浮标就赖着不走。
|
|
@@ -4293,12 +5707,23 @@ window.__ModuleLoader__.load({
|
|
|
4293
5707
|
}
|
|
4294
5708
|
hideButton()
|
|
4295
5709
|
hideQuoteButton()
|
|
5710
|
+
// 卡片开着时同理要收卡片(与主会话"点别处就收卡"同一条规则:那边靠 document 上的
|
|
5711
|
+
// mousedown,帧内的点击父页面收不到)。suppress 也要置上,否则下一拍轮询发现
|
|
5712
|
+
// "还在录"会把卡片又弹回来 —— Esc 那条路同样是这么处理的。
|
|
5713
|
+
if (vcardState.open && state.selection && state.selection.frame === frame) {
|
|
5714
|
+
vcardState.suppress = true
|
|
5715
|
+
closeVCard(true)
|
|
5716
|
+
}
|
|
4296
5717
|
return
|
|
4297
5718
|
}
|
|
4298
5719
|
if (data.kind === 'clear') {
|
|
4299
5720
|
if (state.selection && state.selection.frame === frame) {
|
|
4300
|
-
state.selection = null
|
|
4301
5721
|
state.bridgeActive = false
|
|
5722
|
+
// ⚠️ 卡片开着时**不清选区**:抢焦点必然让帧内选区塌掉(输入法上屏要的正是焦点),
|
|
5723
|
+
// 桥于是报 clear —— 但用户选的那段文字仍然是这一轮的解读对象(发送时要用它,
|
|
5724
|
+
// 麦克风轮询也要靠 state.selection 判断"说完了没有")。
|
|
5725
|
+
// 本文档那条路是一致的:顶层选区被抢焦点搞塌时也只收浮标,不清 state.selection。
|
|
5726
|
+
if (!vcardState.open) state.selection = null
|
|
4302
5727
|
hideButton()
|
|
4303
5728
|
hideQuoteButton()
|
|
4304
5729
|
}
|
|
@@ -4329,6 +5754,17 @@ window.__ModuleLoader__.load({
|
|
|
4329
5754
|
label: labelForFrame(frame, data.sel.label),
|
|
4330
5755
|
}
|
|
4331
5756
|
state.bridgeActive = true
|
|
5757
|
+
// 卡片开着时,帧内的重报(滚动 / 改尺寸 / 挪选区)只是"那段文字动了":
|
|
5758
|
+
// 跟着挪卡片与锚点,**不要**再浮出浮标(浮标与卡片是互斥的两态)。
|
|
5759
|
+
if (vcardState.open) {
|
|
5760
|
+
if (state.selection.frame === frame) {
|
|
5761
|
+
vcardState.anchorRect = rect
|
|
5762
|
+
repositionVCard()
|
|
5763
|
+
return
|
|
5764
|
+
}
|
|
5765
|
+
// 另一帧报了新选区:这张卡已经不是用户在看的东西了,收掉再按新选区走
|
|
5766
|
+
closeVCard(true)
|
|
5767
|
+
}
|
|
4332
5768
|
if (panelOpen) {
|
|
4333
5769
|
// 小窗开着:帧内这条选区也只是"一段可以引用的文字"(上下文由帧内的桥给)
|
|
4334
5770
|
state.quoteSelection = {
|
|
@@ -4518,7 +5954,10 @@ window.__ModuleLoader__.load({
|
|
|
4518
5954
|
* 打开面板的公共路径:真实选区与自检钩子**共用**这一条,
|
|
4519
5955
|
* 否则钩子会绕过缓存(缓存行为只能靠人工点选才能验证)。
|
|
4520
5956
|
*/
|
|
4521
|
-
function
|
|
5957
|
+
function openPanelWithInner(text, context, label, anchor, keyContext, options) {
|
|
5958
|
+
// 这一轮是不是"从语音卡直接进追问":paint() 靠它决定不画翻译/详解两节、
|
|
5959
|
+
// 并且让追问输入框直接可见(见 state.voiceAsk 的几处用法)
|
|
5960
|
+
state.voiceAsk = !!(options && options.askFirst)
|
|
4522
5961
|
// 记下"这次划词发生在哪个会话"——小窗后续所有按会话解析的东西都用它
|
|
4523
5962
|
panelSessionId = currentSessionId()
|
|
4524
5963
|
state.payload = buildPayload(text, context, label)
|
|
@@ -4532,11 +5971,39 @@ window.__ModuleLoader__.load({
|
|
|
4532
5971
|
var cached = cache.get(cacheKey)
|
|
4533
5972
|
state.cacheKey = cacheKey
|
|
4534
5973
|
state.turns = []
|
|
4535
|
-
// 换了一段选中文字 =
|
|
5974
|
+
// 换了一段选中文字 = 换了一次对话:上一段攒的引用**和检索结果**都不该跟过来。
|
|
5975
|
+
//
|
|
5976
|
+
// ⚠️ toolDigest 原来漏在这一步(属遗漏,不是有意保留)。它的语义单位就是"一次对话":
|
|
5977
|
+
// 按历史条目落盘、按条目恢复,宿主发给模型的措辞也是「本会话已经查到的结果」。
|
|
5978
|
+
// 但 state 是整页只建一次的,而它**只在历史回放时被恢复、别处从不复位** ——
|
|
5979
|
+
// 于是只要某段对话搜过东西(比如问过一次新闻),之后**每一段新选区**的追问
|
|
5980
|
+
// 都会把那段结果一并带给模型(用户实测:"每次后面都带着我之前搜的新闻")。
|
|
5981
|
+
//
|
|
5982
|
+
// 只清 digest、不动 tools/thought:那几个在请求开始处另有一份复位,
|
|
5983
|
+
// digest 则该在这里(以及历史回放)动。
|
|
5984
|
+
state.toolDigest = ''
|
|
4536
5985
|
clearQuotes()
|
|
4537
5986
|
state.parts = (cached && cached.parts) || { translation: '', detail: '' }
|
|
4538
5987
|
state.stage = ''
|
|
4539
5988
|
state.raw = ''
|
|
5989
|
+
// 语音卡那条路(options.askFirst):**不请求 /analyze**,直接把说的话当第一条追问发出去。
|
|
5990
|
+
// 面板停在"追问"阶段、翻译与详解两节是空的 —— 这正是"跳过解读"的字面意思。
|
|
5991
|
+
// 缓存与历史回放一律不参与:用户要的是"现在这句",不是"这段文字上次的解读"。
|
|
5992
|
+
if (options && options.askFirst) {
|
|
5993
|
+
state.parts = { translation: '', detail: '' }
|
|
5994
|
+
state.phase = 'done'
|
|
5995
|
+
state.chars = 0
|
|
5996
|
+
state.error = ''
|
|
5997
|
+
state.fromCache = false
|
|
5998
|
+
state.sharedCache = false
|
|
5999
|
+
state.elapsed = 0
|
|
6000
|
+
state.toolBusy = false
|
|
6001
|
+
state.cacheState = ''
|
|
6002
|
+
renderTurns()
|
|
6003
|
+
paint()
|
|
6004
|
+
sendAsk(options.askFirst)
|
|
6005
|
+
return
|
|
6006
|
+
}
|
|
4540
6007
|
renderTurns()
|
|
4541
6008
|
if (cached) {
|
|
4542
6009
|
state.turns = (cached.turns || []).slice()
|
|
@@ -4564,6 +6031,24 @@ window.__ModuleLoader__.load({
|
|
|
4564
6031
|
runRequest(state.payload, cacheKey)
|
|
4565
6032
|
})
|
|
4566
6033
|
}
|
|
6034
|
+
|
|
6035
|
+
/**
|
|
6036
|
+
* 开窗(带"卡片 → 小窗"的交接动画)。
|
|
6037
|
+
*
|
|
6038
|
+
* 为什么动画放在**内容画完之后**、而不是 showPanel 里面:showPanel 那一刻面板还挂着
|
|
6039
|
+
* 上一轮的 DOM(新内容要到后面的 renderTurns / paint 才换上去),在那里量高度量到的是旧的 ——
|
|
6040
|
+
* 实测起点 scaleY 用的是上一轮的 398px 高度,而这一轮内容是 354px。
|
|
6041
|
+
* 起点本来就要把面板压到卡片那一格(视觉上一样),但拿旧高度算缩放会让中段多一折。
|
|
6042
|
+
*
|
|
6043
|
+
* 只有"从语音卡发送"那条路会留下起点(vcardHandoff);其余开窗路径这里是 null,
|
|
6044
|
+
* 行为与以前完全一致(瞬开)。
|
|
6045
|
+
*/
|
|
6046
|
+
function openPanelWith(text, context, label, anchor, keyContext, options) {
|
|
6047
|
+
openPanelWithInner(text, context, label, anchor, keyContext, options)
|
|
6048
|
+
var handoff = vcardHandoff
|
|
6049
|
+
vcardHandoff = null
|
|
6050
|
+
growPanelFromCard(handoff)
|
|
6051
|
+
}
|
|
4567
6052
|
/** 由 Range 还原一个 selection 形状的对象(collectContext 只用到这两个方法)。 */
|
|
4568
6053
|
function rangeSelection(range) {
|
|
4569
6054
|
return {
|
|
@@ -5322,10 +6807,32 @@ window.__ModuleLoader__.load({
|
|
|
5322
6807
|
panel.setAttribute('data-resized', '1')
|
|
5323
6808
|
}
|
|
5324
6809
|
|
|
6810
|
+
/**
|
|
6811
|
+
* 面板的**布局**矩形(不受"卡片 → 小窗"交接动画的 transform 影响)。
|
|
6812
|
+
*
|
|
6813
|
+
* 为什么需要它:交接那 260ms 里面板带着 `translate+scale`,这时 `getBoundingClientRect()`
|
|
6814
|
+
* 给的是**缩放后**的框 —— 谁在这段时间里量它(拖拽、夹进视口、历史列表定位、测试钩子),
|
|
6815
|
+
* 拿到的都是错的(比如把面板拖到卡片那个小格子的位置上去)。
|
|
6816
|
+
* `offsetLeft/Top/Width/Height` 是布局值,不受 transform 影响;面板是 position:fixed,
|
|
6817
|
+
* 所以它们本来就是视口坐标,与 getBoundingClientRect 同源。
|
|
6818
|
+
*/
|
|
6819
|
+
function panelLayoutRect() {
|
|
6820
|
+
var width = panel.offsetWidth
|
|
6821
|
+
var height = panel.offsetHeight
|
|
6822
|
+
var left = panel.offsetLeft
|
|
6823
|
+
var top = panel.offsetTop
|
|
6824
|
+
// 拿不到布局值时(单测的桩 DOM、面板隐藏着量到 0×0)退回渲染矩形:
|
|
6825
|
+
// 那两种情况下也不可能有交接动画,退回是安全的,而且与改动前的行为一致。
|
|
6826
|
+
if (!isFinite(width) || !isFinite(height) || !isFinite(left) || !isFinite(top) || (width === 0 && height === 0)) {
|
|
6827
|
+
var rect = panel.getBoundingClientRect()
|
|
6828
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }
|
|
6829
|
+
}
|
|
6830
|
+
return { left: left, top: top, width: width, height: height, right: left + width, bottom: top + height }
|
|
6831
|
+
}
|
|
6832
|
+
|
|
5325
6833
|
/** 当前尺寸(读实际渲染值,比拿变量算更稳 —— 拖拽过程中变量会漂)。 */
|
|
5326
6834
|
function currentPanelSize() {
|
|
5327
|
-
|
|
5328
|
-
return { w: rect.width, h: rect.height }
|
|
6835
|
+
return { w: panel.offsetWidth, h: panel.offsetHeight }
|
|
5329
6836
|
}
|
|
5330
6837
|
|
|
5331
6838
|
/** 夹进视口:改大小/窗口变化后都调,保证手柄还在屏幕里、小窗不会超出可视区。 */
|
|
@@ -5346,7 +6853,7 @@ window.__ModuleLoader__.load({
|
|
|
5346
6853
|
* 优先保住左上角(那是用户看到的锚点),右边/下边放不下才往回收。
|
|
5347
6854
|
*/
|
|
5348
6855
|
function clampPanelPosition() {
|
|
5349
|
-
var rect =
|
|
6856
|
+
var rect = panelLayoutRect()
|
|
5350
6857
|
var maxLeft = Math.max(4, window.innerWidth - rect.width - 4)
|
|
5351
6858
|
var maxTop = Math.max(4, window.innerHeight - rect.height - 4)
|
|
5352
6859
|
panel.style.left = Math.round(Math.min(Math.max(rect.left, 4), maxLeft)) + 'px'
|
|
@@ -5448,6 +6955,12 @@ window.__ModuleLoader__.load({
|
|
|
5448
6955
|
changed = Math.abs(clamped.w - startSize.w) + Math.abs(clamped.h - startSize.h)
|
|
5449
6956
|
panel.style.width = Math.round(clamped.w) + 'px'
|
|
5450
6957
|
panel.style.height = Math.round(clamped.h) + 'px'
|
|
6958
|
+
// ⚠️ **必须同步抬高 max-height**。
|
|
6959
|
+
// 开窗时 applyPanelSizeOnOpen() 会把"上次记住的高度"写成 max-height 当天花板;
|
|
6960
|
+
// 拖拽如果只写 height,写大的部分会被那条旧天花板直接截掉 ——
|
|
6961
|
+
// 用户实测就是"拖拽的上限不是视口高度减 8,而是上次关闭小窗之前的高度"。
|
|
6962
|
+
// (反过来拖小没问题:height 小于旧天花板,看不出来。)
|
|
6963
|
+
panel.style.maxHeight = Math.round(clamped.h) + 'px'
|
|
5451
6964
|
panelSize = clamped
|
|
5452
6965
|
panel.setAttribute('data-resized', '1')
|
|
5453
6966
|
if (historyList.style.display === 'flex') placeHistoryList()
|
|
@@ -5515,6 +7028,64 @@ window.__ModuleLoader__.load({
|
|
|
5515
7028
|
warmModelCatalog()
|
|
5516
7029
|
}
|
|
5517
7030
|
|
|
7031
|
+
/**
|
|
7032
|
+
* 让小窗"从卡片那一格长出来"(260ms,只动 transform)。
|
|
7033
|
+
*
|
|
7034
|
+
* 起手把面板用 transform 压到卡片矩形上(`translate(dx,dy) scale(sx,sy)`,原点取左上角),
|
|
7035
|
+
* 再回到 `transform:none` —— 于是"卡片 → 小窗"是一段连续的量变,而不是两次 display 切换。
|
|
7036
|
+
*
|
|
7037
|
+
* 几个必须这么写的点:
|
|
7038
|
+
* · 量 `to` 之前先**清掉残留的 transform**:否则量到的是上一次动画的半截几何;
|
|
7039
|
+
* · 先 `transition:none` 摆好起点、强制重排,再开过渡 —— 否则浏览器会把"设起点"和
|
|
7040
|
+
* "设终点"合并成一次样式变更,动画根本不播(插件在 openVCard 里踩过同一个坑);
|
|
7041
|
+
* · 收尾必须把内联的 `transform/transition/transformOrigin` 清干净 —— 面板之后还要被
|
|
7042
|
+
* 拖拽改大小、还要开设置抽屉(抽屉是 inset:0 贴在面板上的),留着 transform 会连累它们;
|
|
7043
|
+
* · 系统要求减少动效时**不做**(与 playCrtOff 同一条规矩)。
|
|
7044
|
+
*/
|
|
7045
|
+
function growPanelFromCard(from) {
|
|
7046
|
+
if (!from || !panel || !from.width || !from.height) return
|
|
7047
|
+
if (prefersReducedMotion()) return
|
|
7048
|
+
panel.style.transition = 'none'
|
|
7049
|
+
panel.style.transform = ''
|
|
7050
|
+
panel.style.transformOrigin = ''
|
|
7051
|
+
var to = panel.getBoundingClientRect()
|
|
7052
|
+
if (!to.width || !to.height) return
|
|
7053
|
+
var sx = from.width / to.width
|
|
7054
|
+
var sy = from.height / to.height
|
|
7055
|
+
var dx = from.left - to.left
|
|
7056
|
+
var dy = from.top - to.top
|
|
7057
|
+
// 卡片正好在面板位置上(差不到 1px)就没必要做 —— 那是"没动",别白播一段
|
|
7058
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(1 - sx) < 0.02 && Math.abs(1 - sy) < 0.02) return
|
|
7059
|
+
panel.style.transformOrigin = '0 0'
|
|
7060
|
+
panel.style.transform = 'translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px) scale(' + sx.toFixed(4) + ',' + sy.toFixed(4) + ')'
|
|
7061
|
+
void panel.offsetWidth
|
|
7062
|
+
panel.style.transition = 'transform ' + PANEL_GROW_MS + 'ms ' + PANEL_GROW_EASE
|
|
7063
|
+
panel.style.transform = 'none'
|
|
7064
|
+
if (panelGrowTimer) {
|
|
7065
|
+
clearTimeout(panelGrowTimer)
|
|
7066
|
+
panelGrowTimer = null
|
|
7067
|
+
}
|
|
7068
|
+
var settle = function () {
|
|
7069
|
+
if (panelGrowTimer) {
|
|
7070
|
+
clearTimeout(panelGrowTimer)
|
|
7071
|
+
panelGrowTimer = null
|
|
7072
|
+
}
|
|
7073
|
+
if (panelGrowHandler) {
|
|
7074
|
+
panel.removeEventListener('transitionend', panelGrowHandler)
|
|
7075
|
+
panelGrowHandler = null
|
|
7076
|
+
}
|
|
7077
|
+
panel.style.transition = ''
|
|
7078
|
+
panel.style.transform = ''
|
|
7079
|
+
panel.style.transformOrigin = ''
|
|
7080
|
+
}
|
|
7081
|
+
panelGrowHandler = function (event) {
|
|
7082
|
+
if (event.target === panel && event.propertyName === 'transform') settle()
|
|
7083
|
+
}
|
|
7084
|
+
panel.addEventListener('transitionend', panelGrowHandler)
|
|
7085
|
+
// transitionend 是主路径;标签页在后台时动画不跑、事件也就不会来,所以另挂兜底
|
|
7086
|
+
panelGrowTimer = later(settle, PANEL_GROW_MS + 140)
|
|
7087
|
+
}
|
|
7088
|
+
|
|
5518
7089
|
function closePanel() {
|
|
5519
7090
|
closeModelMenu()
|
|
5520
7091
|
// 小窗关掉时设置抽屉也一起收:否则下次开窗会直接停在设置页上
|
|
@@ -5523,6 +7094,20 @@ window.__ModuleLoader__.load({
|
|
|
5523
7094
|
closeSettings()
|
|
5524
7095
|
panelOpen = false
|
|
5525
7096
|
panel.style.display = 'none'
|
|
7097
|
+
// 交接动画还没跑完就被关掉:连同它的定时器一起收干净,别把半截 transform 留在面板上
|
|
7098
|
+
// (下次开窗量几何会量到它,拖拽改大小也会被它带偏)
|
|
7099
|
+
if (panelGrowTimer) {
|
|
7100
|
+
clearTimeout(panelGrowTimer)
|
|
7101
|
+
panelGrowTimer = null
|
|
7102
|
+
}
|
|
7103
|
+
if (panelGrowHandler) {
|
|
7104
|
+
panel.removeEventListener('transitionend', panelGrowHandler)
|
|
7105
|
+
panelGrowHandler = null
|
|
7106
|
+
}
|
|
7107
|
+
panel.style.transition = ''
|
|
7108
|
+
panel.style.transform = ''
|
|
7109
|
+
panel.style.transformOrigin = ''
|
|
7110
|
+
vcardHandoff = null
|
|
5526
7111
|
hideHistoryList()
|
|
5527
7112
|
// 小窗收起来了:引用浮标跟着走(它只在"小窗开着"时有意义)
|
|
5528
7113
|
hideQuoteButton()
|
|
@@ -5564,7 +7149,8 @@ window.__ModuleLoader__.load({
|
|
|
5564
7149
|
for (var i = 0; i < state.turns.length; i += 1) {
|
|
5565
7150
|
if (state.turns[i].role === 'user') asked = true
|
|
5566
7151
|
}
|
|
5567
|
-
|
|
7152
|
+
// 语音卡那条路没有解读可展开,别摆一个点了没反应的按钮
|
|
7153
|
+
expandButton.style.display = !hasDetail && translationReady && !asked && !state.voiceAsk ? '' : 'none'
|
|
5568
7154
|
expandButton.disabled = false
|
|
5569
7155
|
return asked
|
|
5570
7156
|
}
|
|
@@ -5592,6 +7178,9 @@ window.__ModuleLoader__.load({
|
|
|
5592
7178
|
var stage = state.stage
|
|
5593
7179
|
var selected = state.payload && state.payload.text ? state.payload.text : state.selection ? state.selection.text : ''
|
|
5594
7180
|
var sections = splitSections(visibleRaw())
|
|
7181
|
+
// 语音卡那条路(选中文字 + 输入法语音 → 直接追问):这一轮**没有解读**,
|
|
7182
|
+
// 所以翻译节整节不画 —— 不是"画个空节、里面写这次没有返回内容"(用户否掉的正是它)。
|
|
7183
|
+
translationSection.root.style.display = state.voiceAsk ? 'none' : ''
|
|
5595
7184
|
|
|
5596
7185
|
// 翻译节:首轮流式期间逐字渲染;已有结果就用结果;一个字都还没有时给等待特效
|
|
5597
7186
|
var live = stripEchoPrefix(stage === 'detail' ? '' : sections.translation || sections.other, selected)
|
|
@@ -5608,6 +7197,7 @@ window.__ModuleLoader__.load({
|
|
|
5608
7197
|
// 创建时 start 事件还没到,state.effort 是空的;用函数实时读,档位才不会显示成兜底值
|
|
5609
7198
|
effortOf: function () { return state.stageEffort || 'low' },
|
|
5610
7199
|
thoughtOf: function () { return state.thought },
|
|
7200
|
+
noticeOf: function () { return state.notice },
|
|
5611
7201
|
})
|
|
5612
7202
|
} else if (state.phase === 'error') {
|
|
5613
7203
|
translationSection.content.textContent = ''
|
|
@@ -5627,19 +7217,6 @@ window.__ModuleLoader__.load({
|
|
|
5627
7217
|
retryButton.style.display = ''
|
|
5628
7218
|
}
|
|
5629
7219
|
|
|
5630
|
-
// 伪造的工具调用被剥掉时,正文下面补一句交代(不是没查,是这轮没工具可用)
|
|
5631
|
-
if (state.phase === 'done' && state.toolResidue) {
|
|
5632
|
-
var hasNote = false
|
|
5633
|
-
for (var ni = 0; ni < translationSection.content.children.length; ni += 1) {
|
|
5634
|
-
if (translationSection.content.children[ni].className === 'dsh-sel-note') hasNote = true
|
|
5635
|
-
}
|
|
5636
|
-
if (!hasNote) {
|
|
5637
|
-
translationSection.content.appendChild(
|
|
5638
|
-
el('div', 'dsh-sel-note', '(本轮没有可用工具:模型输出的工具调用格式已忽略,以上是它凭已有知识的回答)'),
|
|
5639
|
-
)
|
|
5640
|
-
}
|
|
5641
|
-
}
|
|
5642
|
-
|
|
5643
7220
|
// 详解节:只在第二阶段出现(或已缓存过)
|
|
5644
7221
|
var wantDetail = !!(state.parts.detail || stage === 'detail')
|
|
5645
7222
|
detailSection.root.style.display = wantDetail ? '' : 'none'
|
|
@@ -5653,6 +7230,7 @@ window.__ModuleLoader__.load({
|
|
|
5653
7230
|
effort: state.effort,
|
|
5654
7231
|
effortOf: function () { return state.stageEffort || 'high' },
|
|
5655
7232
|
thoughtOf: function () { return state.thought },
|
|
7233
|
+
noticeOf: function () { return state.notice },
|
|
5656
7234
|
})
|
|
5657
7235
|
}
|
|
5658
7236
|
|
|
@@ -5664,9 +7242,10 @@ window.__ModuleLoader__.load({
|
|
|
5664
7242
|
var translationReady = state.phase === 'done' && !!state.parts.translation
|
|
5665
7243
|
var wide = syncPanelStage()
|
|
5666
7244
|
// 配色按**面板实际底色**选(不看系统偏好):主题实现方式怎么变都不影响
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
7245
|
+
var surfaceTheme = currentSurfaceTheme()
|
|
7246
|
+
layer.setAttribute('data-theme', surfaceTheme)
|
|
7247
|
+
panel.setAttribute('data-theme', surfaceTheme)
|
|
7248
|
+
askRow.style.display = wide || translationReady || state.voiceAsk || state.quotes.length > 0 ? '' : 'none'
|
|
5670
7249
|
// 展开 CTA:翻译还在跑(含思考期)时先不出现——那时候该看的是等待特效,
|
|
5671
7250
|
// 摆在下面只会是个灰着的按钮,反而像"没反应";发过追问之后也收起(见 syncExpandCta)。
|
|
5672
7251
|
syncExpandCta()
|
|
@@ -5726,26 +7305,33 @@ window.__ModuleLoader__.load({
|
|
|
5726
7305
|
* · preventScroll:别因为聚焦把页面滚一下(划完词页面跳走最难受)。
|
|
5727
7306
|
*/
|
|
5728
7307
|
function focusComposerOnce() {
|
|
7308
|
+
focusBail = 'pending=' + String(askFocusPending) + ' panel=' + String(panelOpen)
|
|
5729
7309
|
if (!askFocusPending || !panelOpen) return
|
|
7310
|
+
focusBail = 'composerVisible=' + String(composerVisible()) + ' panelSel=' + String(hasPanelSelection())
|
|
5730
7311
|
if (!composerVisible()) return
|
|
5731
7312
|
// 小窗里正有一段选中文字(用户正在划词、准备点「❝ 引用」):**这一下聚焦会把选区清掉**。
|
|
5732
7313
|
// 让位给用户,并且不再补焦 —— 引用浮标是这个功能的主入口,不能因为抢焦点而让它消失。
|
|
5733
7314
|
//(实测:开窗后那一帧恰好落在"首轮刚出完"的自动聚焦上,划好的词会瞬间没了。)
|
|
5734
7315
|
if (hasPanelSelection()) {
|
|
7316
|
+
focusBail = '让位给小窗里的选区'
|
|
5735
7317
|
askFocusPending = false
|
|
5736
7318
|
return
|
|
5737
7319
|
}
|
|
5738
7320
|
var active = document.activeElement
|
|
5739
7321
|
var inside = active && (active === panel || active === askBox || (panel.contains && panel.contains(active)))
|
|
7322
|
+
focusBail = 'active=' + String((active && active.className) || active) + ' typing=' + String(isTypingTarget(active))
|
|
5740
7323
|
// 只有"人家正在别处打字"才值得让路。
|
|
5741
7324
|
// 注意别把普通的按钮焦点也算进去:点浮标时焦点正好落在浮标那个 <button> 上
|
|
5742
7325
|
//(Chrome 点按钮会给它焦点),照"非 body 就不抢"的老写法会把我们自己挡在门外 ——
|
|
5743
7326
|
// 这正是"开窗后没有自动聚焦"的原因。
|
|
5744
7327
|
if (!inside && isTypingTarget(active)) {
|
|
7328
|
+
focusBail = '让位给别处正在打字(' + String((active && active.tagName) || active) + '.' + String((active && active.className) || '')
|
|
7329
|
+
+ ' 链=' + (function(){ var n=active, out=[]; while(n && out.length<5){ out.push(String(n.className||n.tagName)+':'+String((n.style&&n.style.display)||'')); n=n.parentNode } return out.join('>') })() + ')'
|
|
5745
7330
|
askFocusPending = false // 人家真的在别处打字,这次开窗就不再抢
|
|
5746
7331
|
return
|
|
5747
7332
|
}
|
|
5748
7333
|
askFocusPending = false
|
|
7334
|
+
focusBail = 'ok'
|
|
5749
7335
|
focusAskBox()
|
|
5750
7336
|
}
|
|
5751
7337
|
|
|
@@ -5765,12 +7351,45 @@ window.__ModuleLoader__.load({
|
|
|
5765
7351
|
function isTypingTarget(node) {
|
|
5766
7352
|
if (!node || node === askBox) return false
|
|
5767
7353
|
var tag = String(node.tagName || '').toLowerCase()
|
|
5768
|
-
|
|
5769
|
-
|
|
7354
|
+
var editable = tag === 'input' || tag === 'textarea' || tag === 'select' || node.isContentEditable === true
|
|
7355
|
+
if (!editable) return false
|
|
7356
|
+
// 已经隐藏起来的输入框不算"人家正在打字":它既收不到键盘,也不该挡住我们聚焦。
|
|
7357
|
+
// 典型场景就是临时语音卡收起后焦点还挂在它的 textarea 上(display:none 的元素
|
|
7358
|
+
// 在部分浏览器里仍然是 activeElement)。
|
|
7359
|
+
if (typeof node.getClientRects === 'function' && node.getClientRects().length === 0) return false
|
|
7360
|
+
return true
|
|
5770
7361
|
}
|
|
5771
7362
|
|
|
5772
|
-
/**
|
|
7363
|
+
/**
|
|
7364
|
+
* 小窗自己的规则:**一轮输出结束(成功或失败)后,把光标交回输入框**。
|
|
7365
|
+
*
|
|
7366
|
+
* 为什么是规则而不是某个入口的特例:不管是划词解读、语音卡直接追问、还是打开历史,
|
|
7367
|
+
* 用户看完一轮结果之后,下一步十有八九是接着追问 —— 光标本来就该在那儿。
|
|
7368
|
+
*
|
|
7369
|
+
* 与"开窗时聚焦一次"(focusComposerOnce 的首次调用)的区别:
|
|
7370
|
+
* 那次可能因为"人家正在别处打字"而作废并就此作罢;这次是每轮结束都重新申请,
|
|
7371
|
+
* 仍然尊重同一条礼貌规则(不抢正在别处打字的人的焦点)。
|
|
7372
|
+
*/
|
|
7373
|
+
function askFocusAfterTurn() {
|
|
7374
|
+
focusBail = 'panelOpen=' + String(panelOpen)
|
|
7375
|
+
if (!panelOpen) return
|
|
7376
|
+
askFocusPending = true
|
|
7377
|
+
focusComposerOnce()
|
|
7378
|
+
}
|
|
7379
|
+
|
|
7380
|
+
/**
|
|
7381
|
+
* 把光标交给输入框(preventScroll:别因为聚焦把页面滚一下)。
|
|
7382
|
+
*
|
|
7383
|
+
* 先 blur 再 focus:如果输入法的输入上下文还绑在别处(见 closeVCard 里的注释 ——
|
|
7384
|
+
* 典型就是刚收起的临时卡片),单叫 focus() 不足以让它重新绑定到这个输入框;
|
|
7385
|
+
* 走一次 blur → focus 才能把输入法重新挂过来。已经是当前焦点时,blur 是无害的。
|
|
7386
|
+
*/
|
|
5773
7387
|
function focusAskBox() {
|
|
7388
|
+
try {
|
|
7389
|
+
askBox.blur()
|
|
7390
|
+
} catch (error) {
|
|
7391
|
+
/* 桩环境 */
|
|
7392
|
+
}
|
|
5774
7393
|
try {
|
|
5775
7394
|
askBox.focus({ preventScroll: true })
|
|
5776
7395
|
} catch (error) {
|
|
@@ -5798,7 +7417,7 @@ window.__ModuleLoader__.load({
|
|
|
5798
7417
|
/** 内容变高后把面板拉回视口内。 */
|
|
5799
7418
|
function keepInsideViewport() {
|
|
5800
7419
|
if (!panelOpen) return
|
|
5801
|
-
var rect =
|
|
7420
|
+
var rect = panelLayoutRect()
|
|
5802
7421
|
var width = Math.min(rect.width, window.innerWidth - 12)
|
|
5803
7422
|
var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
|
|
5804
7423
|
var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
|
|
@@ -6162,46 +7781,58 @@ window.__ModuleLoader__.load({
|
|
|
6162
7781
|
return folded
|
|
6163
7782
|
}
|
|
6164
7783
|
|
|
6165
|
-
function sanitizeToolResidue(text) {
|
|
6166
|
-
var out = String(text || '')
|
|
6167
|
-
out = out.replace(/<ds_safety_tool_call>[\s\S]*?<\/ds_safety_tool_call>\s*/gi, '')
|
|
6168
|
-
out = out.replace(/<ds_safety_tool_call>[\s\S]*$/i, '')
|
|
6169
|
-
out = out.replace(/<\/?ds_safety_tool_call>\s*/gi, '')
|
|
6170
|
-
// DeepSeek 原生 DSML 工具调用(全角竖线 U+FF5C 包起来的 tool_calls/invoke/parameter)。
|
|
6171
|
-
// host 侧已经在流里滤过一道,这里兜住"旧缓存 / 历史回放 / 升格"这些不经过 host 的路径。
|
|
6172
|
-
// 形态不止一种:marker 与标签名之间**可能有空格**(实测),且可能是逐标签式
|
|
6173
|
-
// (只有 <MARK parameter …> 而没有 tool_calls 包裹)——按"标签栈"整段丢掉最稳。
|
|
6174
|
-
var dsml = '\uFF5C\uFF5CDSML\uFF5C\uFF5C'
|
|
6175
|
-
var tag = new RegExp('</?\\s*' + dsml + '\\s*(?:tool_calls|calls|invoke|parameter)\\b[^>]*>', 'g')
|
|
6176
|
-
out = stripTaggedBlocks(out, tag)
|
|
6177
|
-
out = stripTaggedBlocks(out, /<\/?\s*ds_safety_tool_call\s*>/gi)
|
|
6178
|
-
return out
|
|
6179
|
-
}
|
|
6180
7784
|
|
|
6181
|
-
/**
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
7785
|
+
/**
|
|
7786
|
+
* 当前面板底色是深还是浅('dark' | 'light')。
|
|
7787
|
+
*
|
|
7788
|
+
* 用途:网页输出时告诉 host 该按哪套配色生成页面 —— 深色 Harness 里给一张白底页面很刺眼。
|
|
7789
|
+
* 判据优先用面板自己的 `data-theme`(painter 已经按实际底色设过),
|
|
7790
|
+
* 没有就现算一次 `isDarkSurface`,再不行按浅色兜底。
|
|
7791
|
+
*/
|
|
7792
|
+
function currentSurfaceTheme() {
|
|
7793
|
+
try {
|
|
7794
|
+
// ① 先读 **DSH 自己的信号**:`body[data-ds-dark-theme]`。
|
|
7795
|
+
// 这是 DSH 把"当前配色方案"投影到 document 的结果 —— 用户选的是
|
|
7796
|
+
// 「浅色 / 深色 / 跟随系统」哪一档都无所谓,这里拿到的都是**最终结论**。
|
|
7797
|
+
// 踩过的坑:一开始只看面板的合成底色(isDarkSurface)—— 面板底色取自 DSH 变量、
|
|
7798
|
+
// 自身与祖先的 background-color 往往是透明的,于是被算成浅色。
|
|
7799
|
+
// 用户实测:外观设为"跟随系统(暗)"时,生成的网页却是亮色。
|
|
7800
|
+
var bodyEl = document.body
|
|
7801
|
+
if (bodyEl && bodyEl.getAttribute && bodyEl.getAttribute('data-ds-dark-theme') !== null) return 'dark'
|
|
7802
|
+
// ② html 的 color-scheme:DSH 同时用它驱动原生控件,浅/深都会写上
|
|
7803
|
+
var rootEl = document.documentElement
|
|
7804
|
+
if (rootEl) {
|
|
7805
|
+
var scheme = ''
|
|
7806
|
+
try {
|
|
7807
|
+
scheme = String((window.getComputedStyle(rootEl) || {}).colorScheme || '')
|
|
7808
|
+
} catch (error) {
|
|
7809
|
+
scheme = ''
|
|
7810
|
+
}
|
|
7811
|
+
if (!scheme && rootEl.style) scheme = String(rootEl.style.colorScheme || '')
|
|
7812
|
+
if (scheme.indexOf('dark') >= 0) return 'dark'
|
|
7813
|
+
if (scheme.indexOf('light') >= 0) return 'light'
|
|
6192
7814
|
}
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
7815
|
+
// ③ 面板自己的 data-theme(painter 设过)
|
|
7816
|
+
var attr = panel && panel.getAttribute ? panel.getAttribute('data-theme') : ''
|
|
7817
|
+
if (attr === 'dark' || attr === 'light') return attr
|
|
7818
|
+
// ④ 合成底色兜底(可能误判,所以放在后面)
|
|
7819
|
+
if (isDarkSurface(panel)) return 'dark'
|
|
7820
|
+
// ⑤ 最后才问系统偏好("跟随系统"且上面都读不到时的兜底)
|
|
7821
|
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'
|
|
7822
|
+
return 'light'
|
|
7823
|
+
} catch (error) {
|
|
7824
|
+
return 'light'
|
|
6196
7825
|
}
|
|
6197
|
-
return out
|
|
6198
7826
|
}
|
|
6199
7827
|
|
|
6200
|
-
/**
|
|
7828
|
+
/** 当前可见的原文。
|
|
7829
|
+
*
|
|
7830
|
+
* 以前这里会剥掉"伪造的工具调用"文本(DSML / ds_safety_tool_call),
|
|
7831
|
+
* 现在**不再做任何文本清洗** —— 照主会话的做法:工具每轮都在请求里,
|
|
7832
|
+
* 模型走协议层表达调用,正文里根本不该出现这种标记。
|
|
7833
|
+
* 清洗留在那儿只会再吃一次正文(未闭合就删到结尾)。 */
|
|
6201
7834
|
function visibleRaw() {
|
|
6202
|
-
|
|
6203
|
-
if (cleaned !== state.raw) state.toolResidue = true
|
|
6204
|
-
return cleaned
|
|
7835
|
+
return state.raw
|
|
6205
7836
|
}
|
|
6206
7837
|
|
|
6207
7838
|
// ────────────────────── 小窗对话历史(A′) ──────────────────────
|
|
@@ -6260,6 +7891,8 @@ window.__ModuleLoader__.load({
|
|
|
6260
7891
|
state.raw = ''
|
|
6261
7892
|
state.phase = 'done'
|
|
6262
7893
|
state.error = ''
|
|
7894
|
+
// 打开历史/命中缓存也是"已完成"状态 → 光标交回输入框
|
|
7895
|
+
askFocusAfterTurn()
|
|
6263
7896
|
state.sharedCache = false
|
|
6264
7897
|
state.fromCache = source === 'local'
|
|
6265
7898
|
state.fromHistory = source !== 'local'
|
|
@@ -6328,7 +7961,7 @@ window.__ModuleLoader__.load({
|
|
|
6328
7961
|
* 高度跟面板对齐(上限视口高度),自带滚动条。
|
|
6329
7962
|
*/
|
|
6330
7963
|
function placeHistoryList() {
|
|
6331
|
-
var rect =
|
|
7964
|
+
var rect = panelLayoutRect()
|
|
6332
7965
|
var width = 250
|
|
6333
7966
|
var gap = 10
|
|
6334
7967
|
var viewportW = window.innerWidth || 1200
|
|
@@ -6545,6 +8178,28 @@ window.__ModuleLoader__.load({
|
|
|
6545
8178
|
})
|
|
6546
8179
|
}
|
|
6547
8180
|
|
|
8181
|
+
/**
|
|
8182
|
+
* 撤掉"进行中"类提示:`conclusion-retry`("查询已完成,正在整理结论…")是**过程状态** ——
|
|
8183
|
+
* 正文一开始到达、或者本轮结束,它就该消失。
|
|
8184
|
+
*
|
|
8185
|
+
* 踩过的坑:这条提示写进 answerTurn.notice 之后,**全代码没有任何地方清掉它** ——
|
|
8186
|
+
* 回答早就写完了,黄条还挂在气泡上(用户截图报的现象)。
|
|
8187
|
+
* 其余 notice(effort-clamped / effort-rejected / reasoning-leak)是"这条回答发生了什么"的
|
|
8188
|
+
* 说明,属于该留下的信息,不在这里清。
|
|
8189
|
+
*/
|
|
8190
|
+
function clearTransientNotice(turn) {
|
|
8191
|
+
if (!turn) return false
|
|
8192
|
+
var code = String(turn.noticeCode || '')
|
|
8193
|
+
// conclusion-retry:进行中状态,正文到了就撤;
|
|
8194
|
+
// upstream-retry:上游偶发错误(host 已换档重试),重试成功时正文会到、失败时走 error,
|
|
8195
|
+
// 两种情况都不该把这句一直挂在回答上面。
|
|
8196
|
+
// effort-rejected / reasoning-leak 是"这个模型这一档有问题"的结论性说明,留着。
|
|
8197
|
+
if (code !== 'conclusion-retry' && code !== 'upstream-retry') return false
|
|
8198
|
+
turn.notice = ''
|
|
8199
|
+
turn.noticeCode = ''
|
|
8200
|
+
return true
|
|
8201
|
+
}
|
|
8202
|
+
|
|
6548
8203
|
/** 工具名 → 中文标签(只用于内部摘要,不上界面)。 */
|
|
6549
8204
|
function toolLabel(name) {
|
|
6550
8205
|
// 小窗默认用的是 free-search 插件的工具(见 host 侧 toolNames 注释)
|
|
@@ -6645,6 +8300,51 @@ window.__ModuleLoader__.load({
|
|
|
6645
8300
|
}
|
|
6646
8301
|
}
|
|
6647
8302
|
|
|
8303
|
+
/**
|
|
8304
|
+
* 面板被内容顶出屏幕 → 收回可视区。
|
|
8305
|
+
*
|
|
8306
|
+
* 为什么需要:开窗时"放得下吗"是按**当时**的高度算的(那会儿往往只有一行翻译在流),
|
|
8307
|
+
* 之后正文长高(上限 78vh 或 720px),面板从固定的 top 往下长 —— 而它是 position:fixed、
|
|
8308
|
+
* 页面不滚,被顶出视口的那部分(通常是回复框)就点不到了(实测能挂出去 287px)。
|
|
8309
|
+
*
|
|
8310
|
+
* 用面板**自己**的 ResizeObserver:只在尺寸变化时触发(内容长高、拖拽、阶段切换都算),
|
|
8311
|
+
* 而挪位置(left/top)不改变尺寸 → 不存在"写回自身"的循环。
|
|
8312
|
+
* 每帧只多一次 getBoundingClientRect,且只在真的溢出时才写样式。
|
|
8313
|
+
*/
|
|
8314
|
+
var panelGrowWatch = null
|
|
8315
|
+
|
|
8316
|
+
/**
|
|
8317
|
+
* 复核一次:面板被顶出视口就收回来(只在真的溢出时才写样式)。
|
|
8318
|
+
*
|
|
8319
|
+
* 两条入口都会调它:
|
|
8320
|
+
* · paint() —— 流式/内容变化时的**主力**(渲染本来就节流在 90ms,一次 rect 读约 0.02ms);
|
|
8321
|
+
* · 面板自己的 ResizeObserver —— 兜住不经过 paint 的尺寸变化(阶段切换、composer 长高)。
|
|
8322
|
+
*
|
|
8323
|
+
* ⚠️ 曾经只挂 ResizeObserver,实测**不生效**:内容长高时面板往往已经顶到 max-height,
|
|
8324
|
+
* 伸长的是内部滚动区 —— 面板尺寸不变,观察器根本不触发(冒烟用例里探针显示 open:false、
|
|
8325
|
+
* 且灌内容后零唤醒)。所以主力必须是绘制路径上的复核。
|
|
8326
|
+
*/
|
|
8327
|
+
function ensurePanelInsideViewportNow() {
|
|
8328
|
+
if (!panelOpen || panelInteracting) return
|
|
8329
|
+
var rect = panelLayoutRect()
|
|
8330
|
+
if (rect.bottom <= window.innerHeight - 4) return
|
|
8331
|
+
clampPanelPosition()
|
|
8332
|
+
}
|
|
8333
|
+
|
|
8334
|
+
function ensurePanelInsideViewport() {
|
|
8335
|
+
if (panelGrowWatch || typeof ResizeObserver !== 'function') return null
|
|
8336
|
+
panelGrowWatch = new ResizeObserver(function () {
|
|
8337
|
+
ensurePanelInsideViewportNow()
|
|
8338
|
+
})
|
|
8339
|
+
panelGrowWatch.observe(panel)
|
|
8340
|
+
return function () {
|
|
8341
|
+
if (panelGrowWatch) {
|
|
8342
|
+
panelGrowWatch.disconnect()
|
|
8343
|
+
panelGrowWatch = null
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
}
|
|
8347
|
+
|
|
6648
8348
|
/** 滚轮/触摸:用户自己滚 = 立刻收回跟随权;滚回底部再交还。 */
|
|
6649
8349
|
function noteUserScroll(event) {
|
|
6650
8350
|
var up = event && typeof event.deltaY === 'number' && event.deltaY < 0
|
|
@@ -6723,7 +8423,7 @@ window.__ModuleLoader__.load({
|
|
|
6723
8423
|
if (node.__sig !== sig) {
|
|
6724
8424
|
// 内容没变就别重建:重建会把预览 iframe 一起换掉(多建一次 = 白闪一次)
|
|
6725
8425
|
node.__sig = sig
|
|
6726
|
-
renderRich(node,
|
|
8426
|
+
renderRich(node, turn.text || '…', { settled: turn.streaming !== true })
|
|
6727
8427
|
}
|
|
6728
8428
|
paintNotice(node, turn)
|
|
6729
8429
|
paintQuoteAll(node, turn)
|
|
@@ -6786,7 +8486,7 @@ window.__ModuleLoader__.load({
|
|
|
6786
8486
|
bubble.className += ' dsh-sel-bubble-err'
|
|
6787
8487
|
bubble.textContent = turn.text
|
|
6788
8488
|
} else {
|
|
6789
|
-
renderRich(bubble,
|
|
8489
|
+
renderRich(bubble, turn.text || '…', { settled: turn.streaming !== true })
|
|
6790
8490
|
}
|
|
6791
8491
|
if (turn.role !== 'user') {
|
|
6792
8492
|
paintNotice(bubble, turn)
|
|
@@ -6826,7 +8526,7 @@ window.__ModuleLoader__.load({
|
|
|
6826
8526
|
event.stopPropagation()
|
|
6827
8527
|
var source = existing.__turn
|
|
6828
8528
|
if (!source) return
|
|
6829
|
-
var body = foldForHistory(
|
|
8529
|
+
var body = foldForHistory(source.text)
|
|
6830
8530
|
// 整条引用同样不带上下文:出处写进标签(「小窗第 N 轮回答」),对话本身在历史里
|
|
6831
8531
|
addQuote(body, turnLabelOf(source), { context: '', session: false })
|
|
6832
8532
|
})
|
|
@@ -6835,7 +8535,7 @@ window.__ModuleLoader__.load({
|
|
|
6835
8535
|
|
|
6836
8536
|
/** 把首轮解读结果作为追问的第一条助手上下文(截断,避免过长)。 */
|
|
6837
8537
|
function seedHistoryFromExplanation() {
|
|
6838
|
-
var fallback = splitSections(
|
|
8538
|
+
var fallback = splitSections(state.raw)
|
|
6839
8539
|
var translation = (state.parts.translation || fallback.translation || '').trim()
|
|
6840
8540
|
var detail = (state.parts.detail || fallback.detail || '').trim()
|
|
6841
8541
|
// 写成"前情提要"而不是"我上一轮的回答":否则模型会顺着把两节内容再复述一遍
|
|
@@ -6956,6 +8656,17 @@ window.__ModuleLoader__.load({
|
|
|
6956
8656
|
setStatus('正在取引用的上下文…')
|
|
6957
8657
|
return
|
|
6958
8658
|
}
|
|
8659
|
+
// ⚠️ 到这里才算"收下了这条提问",此刻才清空输入框。
|
|
8660
|
+
//
|
|
8661
|
+
// 以前是**调用方先清空、再调 ask()**:于是"生成中按 Enter"这种被 ask() 拒掉的情况,
|
|
8662
|
+
// 输入框已经被清空 —— 用户打的字直接丢了(只剩一句"还在生成,请稍候…")。
|
|
8663
|
+
// 清空放在这里,就不会再出现"没送出去却清掉了"。
|
|
8664
|
+
// 只清"确实是这条提问的内容":ask() 也可能被程序用别的文本调用(测试钩子等),
|
|
8665
|
+
// 那种情况下输入框里的东西不该被抹掉。
|
|
8666
|
+
if (askBox.value && String(askBox.value).trim() === asked) {
|
|
8667
|
+
askBox.value = ''
|
|
8668
|
+
refreshAskState()
|
|
8669
|
+
}
|
|
6959
8670
|
// 引用是"下一条提问"的:立刻清空(提问已经在手上,别让下一轮又带上)
|
|
6960
8671
|
if (quotes.length > 0) {
|
|
6961
8672
|
state.quotes = []
|
|
@@ -7046,6 +8757,8 @@ window.__ModuleLoader__.load({
|
|
|
7046
8757
|
clearInterval(timer)
|
|
7047
8758
|
state.asking = false
|
|
7048
8759
|
state.toolBusy = false
|
|
8760
|
+
// 本轮结束了:"正在整理结论…"这类过程提示一律撤掉(结论轮一个字没吐时也别留着)
|
|
8761
|
+
clearTransientNotice(answerTurn)
|
|
7049
8762
|
answerTurn.asking = false
|
|
7050
8763
|
answerTurn.streaming = false
|
|
7051
8764
|
clearWaiting(chatLog)
|
|
@@ -7069,6 +8782,10 @@ window.__ModuleLoader__.load({
|
|
|
7069
8782
|
? ''
|
|
7070
8783
|
: '追问完成 · 耗时 ' + ((Date.now() - startedAt) / 1000).toFixed(1) + 's',
|
|
7071
8784
|
)
|
|
8785
|
+
// 一轮**追问**结束(成功 / 失败 / 被停止)—— 与小窗其它轮同一条规则:光标交回输入框。
|
|
8786
|
+
// ⚠️ 这里原来是漏的:首轮的 succeed/fail 有规则,追问这条路径没有,
|
|
8787
|
+
// 所以"追问失败"之后输入框里不出现光标(用户实测)。
|
|
8788
|
+
askFocusAfterTurn()
|
|
7072
8789
|
}
|
|
7073
8790
|
|
|
7074
8791
|
fetch(API, {
|
|
@@ -7081,7 +8798,8 @@ window.__ModuleLoader__.load({
|
|
|
7081
8798
|
sessionId: panelOrCurrentSessionId(),
|
|
7082
8799
|
question: sent,
|
|
7083
8800
|
history: history,
|
|
7084
|
-
|
|
8801
|
+
// 网页输出要跟着当前深浅走:深色 Harness 里给一张白底页面会很刺眼
|
|
8802
|
+
...(state.webAnswer ? { webAnswer: true, theme: currentSurfaceTheme() } : {}),
|
|
7085
8803
|
...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
|
|
7086
8804
|
...(state.effort ? { effort: state.effort } : {}),
|
|
7087
8805
|
...(state.toolDigest ? { toolDigest: state.toolDigest } : {}),
|
|
@@ -7116,6 +8834,8 @@ window.__ModuleLoader__.load({
|
|
|
7116
8834
|
continue
|
|
7117
8835
|
}
|
|
7118
8836
|
if (message.type === 'delta') {
|
|
8837
|
+
// 正文开始到达 → 撤掉"正在整理结论…"这类过程提示(以前它永远挂着)
|
|
8838
|
+
clearTransientNotice(answerTurn)
|
|
7119
8839
|
if (!answerTurn.text.trim()) answerTurn.asking = false
|
|
7120
8840
|
answerTurn.text += message.text
|
|
7121
8841
|
scheduleTurnsRender()
|
|
@@ -7152,15 +8872,16 @@ window.__ModuleLoader__.load({
|
|
|
7152
8872
|
// host 发现档位被上游拒绝(400/401/500)会自动去掉档位重试,这里如实告诉用户,
|
|
7153
8873
|
// 并把"这个模型不吃这个档位"记下来 —— 下次不再拿同一个组合去撞墙
|
|
7154
8874
|
answerTurn.notice = String(message.text || '')
|
|
8875
|
+
// 记下 code:'conclusion-retry' 是"进行中"状态,正文到了/本轮结束就该撤
|
|
8876
|
+
answerTurn.noticeCode = String(message.code || '')
|
|
7155
8877
|
if ((message.code === 'effort-rejected' || message.code === 'reasoning-leak') && message.tier) {
|
|
8878
|
+
// 记下"这个模型的这一档被拒 / 会泄漏",菜单里给它划一道斜线(下次不再撞)
|
|
7156
8879
|
rememberBadTier(message.tier, message.code === 'reasoning-leak' ? 'leak' : 'rejected')
|
|
7157
|
-
//
|
|
7158
|
-
|
|
7159
|
-
writeStore(EFFORT_KEY, '')
|
|
8880
|
+
// **不动**用户选的那一格:设置页与菜单读的就是它(唯一一份数据),
|
|
8881
|
+
// 悄悄清掉会让人以为自己没选过;这一轮该发什么由 host 的换档重试兜。
|
|
7160
8882
|
paintModelPill()
|
|
7161
8883
|
}
|
|
7162
|
-
scheduleTurnsRender()
|
|
7163
|
-
} else if (message.type === 'error') {
|
|
8884
|
+
scheduleTurnsRender() } else if (message.type === 'error') {
|
|
7164
8885
|
finish(message.message || '模型返回错误')
|
|
7165
8886
|
}
|
|
7166
8887
|
}
|
|
@@ -7273,6 +8994,7 @@ window.__ModuleLoader__.load({
|
|
|
7273
8994
|
entry.effort = opts.effort || ''
|
|
7274
8995
|
entry.effortOf = opts.effortOf || null
|
|
7275
8996
|
entry.thoughtOf = opts.thoughtOf || null
|
|
8997
|
+
entry.noticeOf = opts.noticeOf || null
|
|
7276
8998
|
entry.busyOf = opts.busyOf || toolBusyNow
|
|
7277
8999
|
updateWaitClocks()
|
|
7278
9000
|
return entry
|
|
@@ -7304,9 +9026,14 @@ window.__ModuleLoader__.load({
|
|
|
7304
9026
|
if (!entry.hint) continue
|
|
7305
9027
|
var thinking = entry.thoughtOf ? String(entry.thoughtOf() || '') : ''
|
|
7306
9028
|
thinking = thinking.replace(/\s+/g, ' ').trim()
|
|
9029
|
+
var notice = entry.noticeOf ? String(entry.noticeOf() || '') : ''
|
|
9030
|
+
notice = notice.replace(/\s+/g, ' ').trim()
|
|
7307
9031
|
if (entry.busyOf && entry.busyOf()) {
|
|
7308
9032
|
// 检索阶段:只说"在查资料"。查询词/命中/链接都不进小窗(那是模型的原料,不是结果)
|
|
7309
9033
|
entry.hint.textContent = '🔍 正在检索资料…'
|
|
9034
|
+
} else if (notice) {
|
|
9035
|
+
// host 的"正在换档重试 / 上游偶发错误"比思考尾巴更该让人看见(它解释了这段时间去哪了)
|
|
9036
|
+
entry.hint.textContent = '↻ ' + notice.slice(-72)
|
|
7310
9037
|
} else if (thinking) {
|
|
7311
9038
|
// 看得见它在想什么,就不会以为"没反应"了
|
|
7312
9039
|
entry.hint.textContent = '💭 ' + thinking.slice(-72)
|
|
@@ -7341,8 +9068,8 @@ window.__ModuleLoader__.load({
|
|
|
7341
9068
|
state.aborted = false
|
|
7342
9069
|
state.stageEffort = ''
|
|
7343
9070
|
state.thought = ''
|
|
9071
|
+
state.notice = ''
|
|
7344
9072
|
state.tools = []
|
|
7345
|
-
state.toolResidue = false
|
|
7346
9073
|
state.startedAt = Date.now()
|
|
7347
9074
|
state.elapsed = 0
|
|
7348
9075
|
paint()
|
|
@@ -7366,6 +9093,8 @@ window.__ModuleLoader__.load({
|
|
|
7366
9093
|
// 用户收起小窗导致的中止:不算失败,记成"已停止",重开还能接着看
|
|
7367
9094
|
state.phase = state.aborted ? 'paused' : 'error'
|
|
7368
9095
|
state.error = state.aborted ? '' : message
|
|
9096
|
+
// 一轮失败/中止结束 → 同样把光标交回输入框,用户可以直接重试或换个问法
|
|
9097
|
+
askFocusAfterTurn()
|
|
7369
9098
|
state.toolBusy = false
|
|
7370
9099
|
state.elapsed = Date.now() - state.startedAt
|
|
7371
9100
|
stopTicker()
|
|
@@ -7376,6 +9105,8 @@ window.__ModuleLoader__.load({
|
|
|
7376
9105
|
|
|
7377
9106
|
function succeed() {
|
|
7378
9107
|
state.phase = 'done'
|
|
9108
|
+
// 一轮成功结束 → 光标交回输入框(小窗自己的规则,见 askFocusAfterTurn)
|
|
9109
|
+
askFocusAfterTurn()
|
|
7379
9110
|
// 按内容归并(stage 只决定"请求哪一节",不决定结果落到哪个格子):
|
|
7380
9111
|
// 模型偶尔会两节都写,或没写标题,这里都要接住
|
|
7381
9112
|
var parsed = splitSections(visibleRaw())
|
|
@@ -7429,7 +9160,13 @@ window.__ModuleLoader__.load({
|
|
|
7429
9160
|
} else if (message.type === 'delta') {
|
|
7430
9161
|
state.raw += message.text
|
|
7431
9162
|
state.phase = 'streaming'
|
|
9163
|
+
// 正文到了 = 那次换档重试成功了,等待提示里那句"已自动重试"不必再挂着
|
|
9164
|
+
state.notice = ''
|
|
7432
9165
|
dirty = true
|
|
9166
|
+
} else if (message.type === 'notice') {
|
|
9167
|
+
// host 的"正在换档重试 / 上游偶发错误":以前这条分支不存在,全被丢掉。
|
|
9168
|
+
// 不 schedulePaint:由 200ms 的 ticker 写字,和 thought 同一条路。
|
|
9169
|
+
state.notice = String(message.text || '')
|
|
7433
9170
|
} else if (message.type === 'thought') {
|
|
7434
9171
|
// 只留尾巴:等待提示里显示"正在想什么",不给正文添乱;
|
|
7435
9172
|
// 刻意不 schedulePaint —— 由 200ms 的 ticker 写字,思考流不会把界面刷爆
|
|
@@ -7477,6 +9214,11 @@ window.__ModuleLoader__.load({
|
|
|
7477
9214
|
state.chars = state.raw.length
|
|
7478
9215
|
}
|
|
7479
9216
|
paint()
|
|
9217
|
+
// 复核放在**绘制之后**:内容这时候才在 DOM 里,面板这一帧长高的量当场就能夹住
|
|
9218
|
+
//(放在 paint 开头会晚一帧——那帧量到的还是旧高度,实测过)。
|
|
9219
|
+
// 位置只在开窗/拖拽/窗口 resize 时夹过,内容长高这条路径原先没人管 ——
|
|
9220
|
+
// 面板是 position:fixed、页面不滚,顶出去那截(回复框)就点不到了。
|
|
9221
|
+
ensurePanelInsideViewportNow()
|
|
7480
9222
|
})
|
|
7481
9223
|
}
|
|
7482
9224
|
// 流式期间限速(同 scheduleTurnsRender):长内容每帧整块重建会把小窗拖卡
|
|
@@ -7555,8 +9297,8 @@ window.__ModuleLoader__.load({
|
|
|
7555
9297
|
// 用分阶段保存的结果:展开详解后 state.raw 里只有详解那一段,
|
|
7556
9298
|
// 直接拿 raw 解析会导致翻译丢失(升格出来的会话里翻译变成「未记录」)
|
|
7557
9299
|
var sections = {
|
|
7558
|
-
translation: state.parts.translation || splitSections(
|
|
7559
|
-
detail: state.parts.detail || splitSections(
|
|
9300
|
+
translation: state.parts.translation || splitSections(state.raw).translation,
|
|
9301
|
+
detail: state.parts.detail || splitSections(state.raw).detail,
|
|
7560
9302
|
}
|
|
7561
9303
|
state.promoting = true
|
|
7562
9304
|
promoteButton.disabled = true
|
|
@@ -7653,7 +9395,25 @@ window.__ModuleLoader__.load({
|
|
|
7653
9395
|
setQuotePop(false)
|
|
7654
9396
|
})
|
|
7655
9397
|
|
|
9398
|
+
/**
|
|
9399
|
+
* 鼠标是不是按着(拖拽划词进行中)—— 记的是"按下那一刻"。
|
|
9400
|
+
*
|
|
9401
|
+
* 为什么记时刻而不是记一个布尔:松手落在浏览器窗口外时**不会补发 mouseup**,
|
|
9402
|
+
* 布尔一旦卡在 true,`selectionchange` 就被永久忽略 —— 浮标从此再也不会自己收了。
|
|
9403
|
+
* 超过 SELECT_PRESS_STALE_MS 就当已经松开(帧内那条桥用的是同一个兜底,见 bridgeBody)。
|
|
9404
|
+
*
|
|
9405
|
+
* 拖拽期间一律不查选区:时机要和"松手才弹浮标"一致,否则拖到一半浮标就冒出来,
|
|
9406
|
+
* 还跟着手指跑。
|
|
9407
|
+
*/
|
|
9408
|
+
var SELECT_PRESS_STALE_MS = 6000
|
|
9409
|
+
var selectPressedAt = 0
|
|
9410
|
+
|
|
9411
|
+
function selectPressing() {
|
|
9412
|
+
return selectPressedAt > 0 && Date.now() - selectPressedAt < SELECT_PRESS_STALE_MS
|
|
9413
|
+
}
|
|
9414
|
+
|
|
7656
9415
|
var offMouseUp = listen(document, 'mouseup', function (event) {
|
|
9416
|
+
selectPressedAt = 0
|
|
7657
9417
|
// 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
|
|
7658
9418
|
// (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
|
|
7659
9419
|
// 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
|
|
@@ -7667,7 +9427,77 @@ window.__ModuleLoader__.load({
|
|
|
7667
9427
|
}
|
|
7668
9428
|
}, true)
|
|
7669
9429
|
|
|
9430
|
+
/**
|
|
9431
|
+
* 选中的文字**没了** → 浮标跟着消失。
|
|
9432
|
+
*
|
|
9433
|
+
* 为什么非要有这一条:浮标只在 mouseup 和少数几个键的 keyup 之后被检查,
|
|
9434
|
+
* 而"把选中的字删掉"这两种动作都不在那条路径上 ——
|
|
9435
|
+
* · Backspace / Delete / 直接打字覆盖 / 剪切 / 撤回(⌘Z);
|
|
9436
|
+
* · 内容刷新把选中的节点整个换掉(流式回答重渲染、消息重画)。
|
|
9437
|
+
* 于是浮标赖在屏幕上不走,点下去还会解读一段**屏幕上已经不存在**的文字(用户报的正是这个)。
|
|
9438
|
+
*
|
|
9439
|
+
* `selectionchange` 是浏览器对"选区变了"最权威的信号(塌陷 / 被删 / 被程序改动都会发),
|
|
9440
|
+
* 用它兜住所有非鼠标路径;节点被摘掉但选区没塌陷那种(Firefox)另有 selectionDetached 判死。
|
|
9441
|
+
*/
|
|
9442
|
+
var offSelectionChange = listen(document, 'selectionchange', function () {
|
|
9443
|
+
if (selectPressing()) return
|
|
9444
|
+
scheduleCheck()
|
|
9445
|
+
}, true)
|
|
9446
|
+
|
|
9447
|
+
/**
|
|
9448
|
+
* Esc 收语音卡。
|
|
9449
|
+
*
|
|
9450
|
+
* 用 capture 抢在最前面:卡片是当下最内层的东西,Esc 该先退它
|
|
9451
|
+
* (小窗那套 Esc 是"从最内层往外退",这里是同一条规则往外多包一层)。
|
|
9452
|
+
* 已经说出来的字**丢掉**:卡还没发出去,取消就是取消;要留着就先点发送。
|
|
9453
|
+
*/
|
|
9454
|
+
var offVoiceEscape = listen(document, 'keydown', function (event) {
|
|
9455
|
+
if (event.key !== 'Escape' || !vcardState.open || event.defaultPrevented) return
|
|
9456
|
+
event.preventDefault()
|
|
9457
|
+
event.stopPropagation()
|
|
9458
|
+
vcardState.suppress = true
|
|
9459
|
+
closeVCard(true)
|
|
9460
|
+
}, true)
|
|
9461
|
+
|
|
9462
|
+
/**
|
|
9463
|
+
* 浮标亮着的时候,隔几拍确认一次"那段文字还在不在"。
|
|
9464
|
+
*
|
|
9465
|
+
* 为什么光有事件还不够:**Chrome 把选中的节点整个摘掉时会悄悄把选区塌陷,却不发
|
|
9466
|
+
* `selectionchange`**(真浏览器实测:替换 textContent 后 `rangeCount` 仍是 1、
|
|
9467
|
+
* `isCollapsed` 变成 true,而事件计数一个都不涨)。流式回答重渲染 / 消息重画走的正是这条路 ——
|
|
9468
|
+
* 只靠事件的话,浮标会一直亮着,点下去解读的是一段屏幕上已经没有的文字。
|
|
9469
|
+
*
|
|
9470
|
+
* 代价是可控的:**只在浮标可见的那几拍里干活**(它不可见时这一拍立刻返回,
|
|
9471
|
+
* 连选区都不读),而且只有发现选区没了/锚点被摘掉时才去排一次检查 ——
|
|
9472
|
+
* 正常亮着的那几拍不做任何 DOM 写。
|
|
9473
|
+
*/
|
|
9474
|
+
var FLOAT_WATCH_MS = 400
|
|
9475
|
+
|
|
9476
|
+
function watchFloatLiveness() {
|
|
9477
|
+
// 拖拽划词进行中不查:浮标要等松手才出现(与 mouseup 那条路径同一套时机)
|
|
9478
|
+
if (selectPressing()) return
|
|
9479
|
+
if (button.style.display !== 'inline-flex' && quoteButton.style.display !== 'inline-flex') return
|
|
9480
|
+
var selection = window.getSelection && window.getSelection()
|
|
9481
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
9482
|
+
// 帧内那条选区**不在本文档**:点浮标会让本文档的选区塌掉,别把它误当成"文字没了"
|
|
9483
|
+
if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
|
|
9484
|
+
scheduleCheck()
|
|
9485
|
+
return
|
|
9486
|
+
}
|
|
9487
|
+
var range = null
|
|
9488
|
+
try {
|
|
9489
|
+
range = selection.getRangeAt(0)
|
|
9490
|
+
} catch (error) {
|
|
9491
|
+
return
|
|
9492
|
+
}
|
|
9493
|
+
// 选区没塌陷、锚点却被摘掉了(Firefox 的行为):也当"文字没了"
|
|
9494
|
+
if (selectionDetached(range)) scheduleCheck()
|
|
9495
|
+
}
|
|
9496
|
+
|
|
9497
|
+
var floatWatchId = setInterval(watchFloatLiveness, FLOAT_WATCH_MS)
|
|
9498
|
+
|
|
7670
9499
|
var offMouseDown = listen(document, 'mousedown', function (event) {
|
|
9500
|
+
selectPressedAt = Date.now()
|
|
7671
9501
|
var target = event.target
|
|
7672
9502
|
// 模型菜单:点它自己和胶囊之外**任何地方**都收起。
|
|
7673
9503
|
// 必须放在最前面 —— 下面的 historyButton / pill / panel 分支都会 return,
|
|
@@ -7734,9 +9564,8 @@ window.__ModuleLoader__.load({
|
|
|
7734
9564
|
// —— 悬浮状态胶囊:点一下回到最近一次小窗 ——
|
|
7735
9565
|
// —— 模型 + 推理等级(追问档) ——
|
|
7736
9566
|
var MODEL_KEY = 'dsh-selection-explain:model' // 存 "provider\tmodel"
|
|
7737
|
-
|
|
7738
|
-
var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '
|
|
7739
|
-
var FALLBACK_TIERS = ['off', 'low', 'high', 'max']
|
|
9567
|
+
/** 四个格子的中文名(设置页与小窗共用一套词;`medium` 只在标注模型自己的档位时出现)。 */
|
|
9568
|
+
var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '最高' }
|
|
7740
9569
|
/**
|
|
7741
9570
|
* 模型清单缓存。
|
|
7742
9571
|
*
|
|
@@ -7896,6 +9725,11 @@ window.__ModuleLoader__.load({
|
|
|
7896
9725
|
modelPillTier.textContent = tier ? '· ' + tierLabel(tier) : ''
|
|
7897
9726
|
modelPill.title = (provider ? provider + ' · ' : '') + name + (tier ? ' · 推理等级 ' + tierLabel(tier) : '') +
|
|
7898
9727
|
'(模型与追问思考强度)'
|
|
9728
|
+
// 胶囊是"当前模型"的**唯一**统一落点:换模型的所有路径最后都会走到这里
|
|
9729
|
+
//(设置抽屉的模型下拉、小窗的模型菜单、跟随主会话时主界面换模型…),
|
|
9730
|
+
// 设置页那行"四格 → 真实档位"也在这里跟着重画 —— 早先只在 chooseSharedModel 里画,
|
|
9731
|
+
// 于是"从抽屉的下拉里换模型"那行纹丝不动(用户报的正是这个)。
|
|
9732
|
+
paintEffortMapHint()
|
|
7899
9733
|
}
|
|
7900
9734
|
|
|
7901
9735
|
/**
|
|
@@ -8017,44 +9851,51 @@ window.__ModuleLoader__.load({
|
|
|
8017
9851
|
modelMenu.appendChild(el('div', 'dsh-sel-pickersplit'))
|
|
8018
9852
|
modelMenu.appendChild(el('div', 'dsh-sel-pickergroup', '推理等级(追问档)'))
|
|
8019
9853
|
var tiers = el('div', 'dsh-sel-tiers')
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
9854
|
+
/**
|
|
9855
|
+
* 档位按钮 = 与设置页**同一套四个格子**(关/低/高/最高)。
|
|
9856
|
+
*
|
|
9857
|
+
* 以前这里列的是"该模型自己声明的档位"(可能是 minimal/medium/xhigh 这种),于是
|
|
9858
|
+
* 两边词汇不同、还要把模型档位反向折回格子;现在两边同词同源:这里的选中值就是
|
|
9859
|
+
* 配置里的 `chatReasoningEffort`,改一边另一边跟着变,没有任何映射。
|
|
9860
|
+
* 格子落到模型哪个真实档位由 host 算(见设置页那行"当前模型:关 → …")。
|
|
9861
|
+
*/
|
|
9862
|
+
var tierItem = effectiveItem()
|
|
9863
|
+
var tierMap = (tierItem && tierItem.effortMap) || null
|
|
8028
9864
|
var badTiers = badTiersOf()
|
|
8029
9865
|
// host 在进程内学到的:这个模型"关"档会把思考写进正文
|
|
8030
9866
|
var leaky = effectiveChoice()
|
|
8031
9867
|
if (leaky && modelCatalog.leakyOff.indexOf(leaky.provider + '/' + leaky.model) >= 0) {
|
|
8032
9868
|
badTiers = { ...badTiers, off: 'leak' }
|
|
8033
9869
|
}
|
|
8034
|
-
var
|
|
8035
|
-
for (var t = 0; t <
|
|
8036
|
-
;(function (
|
|
8037
|
-
|
|
9870
|
+
var activeSlot = chatSlot()
|
|
9871
|
+
for (var t = 0; t < SLOT_ORDER.length; t += 1) {
|
|
9872
|
+
;(function (slot) {
|
|
9873
|
+
// 这一格的落点(模型真实档位):用来把"实测被拒/会泄漏"的标注对上来
|
|
9874
|
+
var target = (tierMap && tierMap[slot]) || slot
|
|
9875
|
+
var bad = badTiers[slot] || (target !== slot ? badTiers[target] : '')
|
|
9876
|
+
var button = el('button', 'dsh-sel-tierbtn', SLOT_LABEL[slot])
|
|
8038
9877
|
button.type = 'button'
|
|
8039
|
-
button.setAttribute('data-on',
|
|
8040
|
-
|
|
9878
|
+
button.setAttribute('data-on', slot === activeSlot ? '1' : '0')
|
|
9879
|
+
button.setAttribute('data-slot', slot)
|
|
9880
|
+
if (bad) {
|
|
8041
9881
|
button.setAttribute('data-bad', '1')
|
|
8042
9882
|
button.title =
|
|
8043
|
-
|
|
8044
|
-
? '
|
|
8045
|
-
: '
|
|
9883
|
+
bad === 'leak'
|
|
9884
|
+
? '实测该模型在这一档会把思考写进正文:已自动改用「低」档'
|
|
9885
|
+
: '实测该模型不支持这一档:选它会自动回退到默认档'
|
|
8046
9886
|
}
|
|
9887
|
+
if (target !== slot) button.title = (button.title ? button.title + ' · ' : '') + '该模型发 ' + target
|
|
8047
9888
|
listen(button, 'click', function (event) {
|
|
8048
9889
|
event.preventDefault()
|
|
8049
9890
|
event.stopPropagation()
|
|
8050
|
-
|
|
8051
|
-
|
|
9891
|
+
// 唯一写入口:写的就是设置页那一行读的那个值
|
|
9892
|
+
setChatSlot(slot)
|
|
8052
9893
|
paintModelPill()
|
|
8053
9894
|
renderModelMenu()
|
|
8054
|
-
setStatus('追问档位:' +
|
|
9895
|
+
setStatus('追问档位:' + SLOT_LABEL[slot] + (target !== slot ? '(该模型发 ' + target + ')' : ''))
|
|
8055
9896
|
})
|
|
8056
9897
|
tiers.appendChild(button)
|
|
8057
|
-
})(
|
|
9898
|
+
})(SLOT_ORDER[t])
|
|
8058
9899
|
}
|
|
8059
9900
|
modelMenu.appendChild(tiers)
|
|
8060
9901
|
modelMenu.appendChild(
|
|
@@ -8131,8 +9972,8 @@ window.__ModuleLoader__.load({
|
|
|
8131
9972
|
// 模型/档位:从 localStorage 恢复;没存过就跟随"当前路由 + 配置档位"(ping 里就有)
|
|
8132
9973
|
// 从 host 的统一设置恢复模型,不读取旧浏览器模型覆盖。
|
|
8133
9974
|
loadSettings(false)
|
|
8134
|
-
|
|
8135
|
-
|
|
9975
|
+
// 「追问」档位不再有浏览器本地覆盖:设置页那一行、小窗那四个格子、发出去的请求
|
|
9976
|
+
// 读的都是同一份配置(chatReasoningEffort,加载完由 applyServerValues 同步进 state.effort)
|
|
8136
9977
|
paintModelPill()
|
|
8137
9978
|
fetch(PING + (currentSessionId() ? '?sessionId=' + encodeURIComponent(currentSessionId()) : ''), {
|
|
8138
9979
|
headers: { accept: 'application/json' },
|
|
@@ -8267,7 +10108,20 @@ window.__ModuleLoader__.load({
|
|
|
8267
10108
|
})
|
|
8268
10109
|
|
|
8269
10110
|
var offKeyDown = listen(document, 'keydown', function (event) {
|
|
8270
|
-
if (event.key !== 'Escape' || !panelOpen) return
|
|
10111
|
+
if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
|
|
10112
|
+
// 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
|
|
10113
|
+
var fullViewFrame = document.querySelector('[data-dsh-full-view]')
|
|
10114
|
+
if (fullViewFrame) {
|
|
10115
|
+
var escapeTarget = event.target
|
|
10116
|
+
var canFindEscapeOwner = escapeTarget && typeof escapeTarget.closest === 'function'
|
|
10117
|
+
var expandedChatSelector = '[data-dsh-floating-chat]:not([data-dsh-chat-minimized]):not([data-dsh-chat-hidden])'
|
|
10118
|
+
var activeFloatingWindow = fullViewFrame.getAttribute('data-dsh-active-floating-window')
|
|
10119
|
+
if (activeFloatingWindow === 'chat' && document.querySelector(expandedChatSelector)) return
|
|
10120
|
+
// 与旧版完整视图共存时仍保留原来的键盘焦点判断。
|
|
10121
|
+
if (activeFloatingWindow === null && canFindEscapeOwner && escapeTarget.closest(expandedChatSelector)) return
|
|
10122
|
+
// 宿主菜单可能挂在聊天外;让它先消费 Esc,不连带关闭解读。
|
|
10123
|
+
if (!panel.contains(escapeTarget) && !historyList.contains(escapeTarget) && canFindEscapeOwner && escapeTarget.closest('[role="menu"], [role="listbox"], [data-trigger-menu]')) return
|
|
10124
|
+
}
|
|
8271
10125
|
// Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
|
|
8272
10126
|
// ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
|
|
8273
10127
|
// ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
|
|
@@ -8356,8 +10210,7 @@ window.__ModuleLoader__.load({
|
|
|
8356
10210
|
var question = askBox.value
|
|
8357
10211
|
// 空输入框照发**只在挂了引用时**成立(引用本身就是用户要送出去的内容)
|
|
8358
10212
|
if (!question.trim() && state.quotes.length === 0) return
|
|
8359
|
-
|
|
8360
|
-
refreshAskState()
|
|
10213
|
+
// 清空交给 ask():它收下才清(生成中被拒时保留你打的字)
|
|
8361
10214
|
ask(question)
|
|
8362
10215
|
})
|
|
8363
10216
|
|
|
@@ -8373,8 +10226,7 @@ window.__ModuleLoader__.load({
|
|
|
8373
10226
|
}
|
|
8374
10227
|
var question = askBox.value
|
|
8375
10228
|
if (!question.trim() && state.quotes.length === 0) return
|
|
8376
|
-
|
|
8377
|
-
refreshAskState()
|
|
10229
|
+
// 清空交给 ask():它收下才清
|
|
8378
10230
|
ask(question)
|
|
8379
10231
|
})
|
|
8380
10232
|
|
|
@@ -9633,7 +11485,7 @@ window.__ModuleLoader__.load({
|
|
|
9633
11485
|
if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
|
|
9634
11486
|
if (settingsButton.contains(event.target) || historyButton.contains(event.target)) return
|
|
9635
11487
|
event.preventDefault()
|
|
9636
|
-
var rect =
|
|
11488
|
+
var rect = panelLayoutRect()
|
|
9637
11489
|
var offsetX = event.clientX - rect.left
|
|
9638
11490
|
var offsetY = event.clientY - rect.top
|
|
9639
11491
|
panelInteracting = true
|
|
@@ -9652,6 +11504,7 @@ window.__ModuleLoader__.load({
|
|
|
9652
11504
|
window.addEventListener('mouseup', up, true)
|
|
9653
11505
|
})
|
|
9654
11506
|
|
|
11507
|
+
ensurePanelInsideViewport()
|
|
9655
11508
|
bindResizeGrip(gripCorner, 'se')
|
|
9656
11509
|
bindResizeGrip(gripRight, 'e')
|
|
9657
11510
|
bindResizeGrip(gripBottom, 's')
|
|
@@ -9669,6 +11522,58 @@ window.__ModuleLoader__.load({
|
|
|
9669
11522
|
|
|
9670
11523
|
// —— 调试钩子(自动化验证用) ——
|
|
9671
11524
|
window.__dshSelectionExplain = {
|
|
11525
|
+
/**
|
|
11526
|
+
* 语音卡(选中文字 + 输入法正在语音输入 → 就地追问)的状态。
|
|
11527
|
+
* 给自动化验证用:能不能弹、有没有焦点、几何对不对,全在这里读。
|
|
11528
|
+
*/
|
|
11529
|
+
voiceCard: function () {
|
|
11530
|
+
return {
|
|
11531
|
+
open: vcardState.open,
|
|
11532
|
+
closing: vcardState.closing,
|
|
11533
|
+
watching: !!vcardState.timer,
|
|
11534
|
+
/** 正在等文字定稿(松手后输入法还会智能整理);settleFor = 距上次文字变化多久(毫秒)。 */
|
|
11535
|
+
settling: !!vcardState.settleTimer,
|
|
11536
|
+
settleFor: vcardState.settleStableAt ? Date.now() - vcardState.settleStableAt : -1,
|
|
11537
|
+
voiced: !!(vcard && vcard.getAttribute('data-voiced') === '1'),
|
|
11538
|
+
hasText: !!(vcard && vcard.getAttribute('data-text') === '1'),
|
|
11539
|
+
text: vcardBox ? vcardBox.value : '',
|
|
11540
|
+
focused: !!(vcardBox && document.activeElement === vcardBox),
|
|
11541
|
+
geometry: vcard
|
|
11542
|
+
? {
|
|
11543
|
+
left: vcard.style.left,
|
|
11544
|
+
top: vcard.style.top,
|
|
11545
|
+
width: vcard.style.width,
|
|
11546
|
+
height: vcard.style.height,
|
|
11547
|
+
}
|
|
11548
|
+
: null,
|
|
11549
|
+
}
|
|
11550
|
+
},
|
|
11551
|
+
/** 手工把卡打开/收起/发送(测试与调试用;正常路径由麦克风轮询驱动)。 */
|
|
11552
|
+
/** 调试:最后一次自动聚焦卡在哪一步。 */
|
|
11553
|
+
focusBail: function () {
|
|
11554
|
+
return focusBail
|
|
11555
|
+
},
|
|
11556
|
+
/** 重新拉一次设置(测试用:设置只在启动时读一次,桩里改完值要能刷新)。 */
|
|
11557
|
+
reloadSettings: function () {
|
|
11558
|
+
loadSettings(true)
|
|
11559
|
+
return Object.assign({}, settingsLive)
|
|
11560
|
+
},
|
|
11561
|
+
voiceCardOpen: function () {
|
|
11562
|
+
openVCard()
|
|
11563
|
+
return window.__dshSelectionExplain.voiceCard()
|
|
11564
|
+
},
|
|
11565
|
+
voiceCardClose: function () {
|
|
11566
|
+
closeVCard()
|
|
11567
|
+
return window.__dshSelectionExplain.voiceCard()
|
|
11568
|
+
},
|
|
11569
|
+
voiceCardSend: function (text) {
|
|
11570
|
+
if (vcardBox && text !== undefined) {
|
|
11571
|
+
vcardBox.value = String(text)
|
|
11572
|
+
syncVCardText()
|
|
11573
|
+
}
|
|
11574
|
+
sendVCard()
|
|
11575
|
+
return true
|
|
11576
|
+
},
|
|
9672
11577
|
/** 重新读一次 host 上的小窗尺寸(测试用;正常路径只在挂载时读一次)。 */
|
|
9673
11578
|
loadPanelSize: function () {
|
|
9674
11579
|
panelSizeAsked = false
|
|
@@ -9677,7 +11582,7 @@ window.__ModuleLoader__.load({
|
|
|
9677
11582
|
},
|
|
9678
11583
|
/** 当前小窗几何(测试用:直接读渲染值,不经过变量)。 */
|
|
9679
11584
|
panelGeom: function () {
|
|
9680
|
-
var rect =
|
|
11585
|
+
var rect = panelLayoutRect()
|
|
9681
11586
|
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, resized: panel.getAttribute('data-resized') === '1' }
|
|
9682
11587
|
},
|
|
9683
11588
|
/** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
|
|
@@ -9907,8 +11812,7 @@ window.__ModuleLoader__.load({
|
|
|
9907
11812
|
stageEffort: state.stageEffort,
|
|
9908
11813
|
raw: state.raw,
|
|
9909
11814
|
model: state.model,
|
|
9910
|
-
sections: splitSections(
|
|
9911
|
-
toolResidue: state.toolResidue,
|
|
11815
|
+
sections: splitSections(state.raw),
|
|
9912
11816
|
tools: state.tools.length,
|
|
9913
11817
|
toolBusy: state.toolBusy,
|
|
9914
11818
|
toolDigest: state.toolDigest,
|
|
@@ -9927,10 +11831,6 @@ window.__ModuleLoader__.load({
|
|
|
9927
11831
|
pill: function () {
|
|
9928
11832
|
return pill
|
|
9929
11833
|
},
|
|
9930
|
-
/** 自检用:残渣清理(供单测直接打表)。 */
|
|
9931
|
-
stripResidue: function (text) {
|
|
9932
|
-
return sanitizeToolResidue(text)
|
|
9933
|
-
},
|
|
9934
11834
|
/** 自检用:模型/档位当前值 + 已加载的目录规模。 */
|
|
9935
11835
|
modelState: function () {
|
|
9936
11836
|
return {
|
|
@@ -10147,6 +12047,9 @@ window.__ModuleLoader__.load({
|
|
|
10147
12047
|
offQuotePopEnd()
|
|
10148
12048
|
offMouseUp()
|
|
10149
12049
|
offKeyUp()
|
|
12050
|
+
offSelectionChange()
|
|
12051
|
+
offVoiceEscape()
|
|
12052
|
+
if (floatWatchId) clearInterval(floatWatchId)
|
|
10150
12053
|
offMouseDown()
|
|
10151
12054
|
offButtonDown()
|
|
10152
12055
|
offButtonClick()
|
|
@@ -10195,6 +12098,10 @@ window.__ModuleLoader__.load({
|
|
|
10195
12098
|
offBodyTouch()
|
|
10196
12099
|
offBodyScroll()
|
|
10197
12100
|
if (chatGrowWatch) chatGrowWatch.disconnect()
|
|
12101
|
+
if (panelGrowWatch) {
|
|
12102
|
+
panelGrowWatch.disconnect()
|
|
12103
|
+
panelGrowWatch = null
|
|
12104
|
+
}
|
|
10198
12105
|
if (pillPlacerId) clearTimeout(pillPlacerId)
|
|
10199
12106
|
if (pillObserver) {
|
|
10200
12107
|
try {
|