@yfwu2020/dsh-selection-explain 0.9.1 → 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 -553
- package/lib/client.js +1896 -120
- 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 +235 -11
- package/scripts/test-client.mjs +1353 -54
- package/scripts/test-effort-map.mjs +129 -0
- package/scripts/test-effort-retry.mjs +109 -0
- package/scripts/test-filters.mjs +5 -88
- package/scripts/test-mic-idle.mjs +158 -0
- package/src/client/index.js +1896 -120
- package/src/index.ts +674 -179
- package/src/settings.ts +3 -0
package/lib/client.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
|
|
@@ -950,6 +1043,125 @@ window.__ModuleLoader__.load({
|
|
|
950
1043
|
'.dsh-sel-sbtn[data-danger="1"]{color:var(--dsw-alias-state-error-primary,#e5484d);',
|
|
951
1044
|
'border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 45%,transparent)}',
|
|
952
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}}',
|
|
953
1165
|
].join('')
|
|
954
1166
|
|
|
955
1167
|
// ────────────────────── Markdown 轻渲染(全 DOM,无 innerHTML) ──────────────────────
|
|
@@ -2203,7 +2415,6 @@ window.__ModuleLoader__.load({
|
|
|
2203
2415
|
/** 有工具正在跑(等待提示说"正在检索资料",不显示是哪个工具、查了什么)。 */
|
|
2204
2416
|
toolBusy: false,
|
|
2205
2417
|
/** 模型输出了伪造的工具调用格式(已被剥掉)。 */
|
|
2206
|
-
toolResidue: false,
|
|
2207
2418
|
/** 这次内容是"从历史回放"出来的(不是本次请求)。 */
|
|
2208
2419
|
fromHistory: false,
|
|
2209
2420
|
/** 当前选区的本地缓存 key。 */
|
|
@@ -2247,6 +2458,12 @@ window.__ModuleLoader__.load({
|
|
|
2247
2458
|
* 追问于是永远发 low —— 表现为"推理等级的设置没起作用"。
|
|
2248
2459
|
*/
|
|
2249
2460
|
stageEffort: '',
|
|
2461
|
+
/**
|
|
2462
|
+
* 首轮/详解期间 host 发的 notice(换档重试、上游偶发错误…)。
|
|
2463
|
+
* 以前首轮那条 SSE 处理器**没有 notice 分支**,host 说的话全被丢掉 ——
|
|
2464
|
+
* 用户只看到"卡了十几秒",不知道插件正在换档重试。
|
|
2465
|
+
*/
|
|
2466
|
+
notice: '',
|
|
2250
2467
|
/** 网页模式(小窗开关):复杂问题默认用网页回答。 */
|
|
2251
2468
|
webAnswer: false,
|
|
2252
2469
|
/** 追问是否正在流式返回。 */
|
|
@@ -2566,6 +2783,16 @@ window.__ModuleLoader__.load({
|
|
|
2566
2783
|
var settingsRailButtons = []
|
|
2567
2784
|
var settingsPanes = []
|
|
2568
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 = []
|
|
2569
2796
|
/** 自绘下拉的菜单节点(挂在抽屉上,重渲染时要一起清掉,否则会叠出第二份浮层)。 */
|
|
2570
2797
|
var settingsMenus = []
|
|
2571
2798
|
var settingsFetching = false
|
|
@@ -2712,6 +2939,7 @@ window.__ModuleLoader__.load({
|
|
|
2712
2939
|
if (forceRoute) entry.filled = false
|
|
2713
2940
|
fillModelMenu(entry, entry.catalog)
|
|
2714
2941
|
paintModelPick(entry)
|
|
2942
|
+
paintEffortMapHint()
|
|
2715
2943
|
})
|
|
2716
2944
|
fetchRoute(sessionId, isChat).then(applyFollowRoute)
|
|
2717
2945
|
}
|
|
@@ -2840,6 +3068,79 @@ window.__ModuleLoader__.load({
|
|
|
2840
3068
|
return (provider ? provider + ' / ' : '') + model
|
|
2841
3069
|
}
|
|
2842
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
|
+
|
|
2843
3144
|
function settingsDirtyCount() {
|
|
2844
3145
|
var n = 0
|
|
2845
3146
|
Object.keys(settingsLive).forEach(function (key) {
|
|
@@ -2882,6 +3183,8 @@ window.__ModuleLoader__.load({
|
|
|
2882
3183
|
})
|
|
2883
3184
|
updateSettingDependencies()
|
|
2884
3185
|
syncComposerModel()
|
|
3186
|
+
// 「追问」档位的内存镜像跟着配置走(四个格子之一;列表读到 stages.chat 之前也靠它兜底)
|
|
3187
|
+
state.effort = settingsLive.chatReasoningEffort || ''
|
|
2885
3188
|
applySelectionLimit(settingsLive)
|
|
2886
3189
|
if (!state.modelChoice) refreshModelRow(settingsByKey.modelChoice, true)
|
|
2887
3190
|
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
@@ -2906,6 +3209,7 @@ window.__ModuleLoader__.load({
|
|
|
2906
3209
|
syncComposerModel()
|
|
2907
3210
|
var entry = settingsByKey.modelChoice
|
|
2908
3211
|
if (entry) { paintModelPick(entry); repaintModelMenu(entry) }
|
|
3212
|
+
paintEffortMapHint()
|
|
2909
3213
|
loadSettings(false)
|
|
2910
3214
|
renderSettingsNote('')
|
|
2911
3215
|
scheduleSettingsSave()
|
|
@@ -2963,7 +3267,13 @@ window.__ModuleLoader__.load({
|
|
|
2963
3267
|
entry.cells[i].setAttribute('aria-checked', picked ? 'true' : 'false')
|
|
2964
3268
|
if (picked) on = entry.cells[i]
|
|
2965
3269
|
}
|
|
2966
|
-
|
|
3270
|
+
// 值不在四格里(小窗选了模型自己的 medium/xhigh 这种):一个格子都不点亮、
|
|
3271
|
+
// 滑块也收起来 —— 留着上一次的位置会假装它还选中着
|
|
3272
|
+
if (!on) {
|
|
3273
|
+
entry.pill.style.display = 'none'
|
|
3274
|
+
return
|
|
3275
|
+
}
|
|
3276
|
+
entry.pill.style.display = ''
|
|
2967
3277
|
entry.pill.style.left = String(on.offsetLeft) + 'px'
|
|
2968
3278
|
entry.pill.style.width = String(on.offsetWidth) + 'px'
|
|
2969
3279
|
}
|
|
@@ -3047,7 +3357,7 @@ window.__ModuleLoader__.load({
|
|
|
3047
3357
|
var entry = { spec: spec, row: row, seg: seg, pill: pill, cells: [] }
|
|
3048
3358
|
var options = spec.options || []
|
|
3049
3359
|
options.forEach(function (option) {
|
|
3050
|
-
var cell = el('button', 'dsh-sel-segcell',
|
|
3360
|
+
var cell = el('button', 'dsh-sel-segcell', SLOT_LABEL[option] || option)
|
|
3051
3361
|
cell.type = 'button'
|
|
3052
3362
|
cell.setAttribute('role', 'radio')
|
|
3053
3363
|
cell.setAttribute('data-value', option)
|
|
@@ -3056,7 +3366,8 @@ window.__ModuleLoader__.load({
|
|
|
3056
3366
|
seg.setAttribute('data-value', option)
|
|
3057
3367
|
paintSegPill(entry)
|
|
3058
3368
|
settingsLive[spec.key] = option
|
|
3059
|
-
|
|
3369
|
+
// 与小窗那个控件共用同一个写入口(同一份数据:配置里的 chatReasoningEffort)
|
|
3370
|
+
if (spec.key === 'chatReasoningEffort') { setChatSlot(option); paintModelPill() }
|
|
3060
3371
|
renderSettingsNote('')
|
|
3061
3372
|
scheduleSettingsSave()
|
|
3062
3373
|
})
|
|
@@ -3127,6 +3438,7 @@ window.__ModuleLoader__.load({
|
|
|
3127
3438
|
settingsRailButtons = []
|
|
3128
3439
|
settingsPanes = []
|
|
3129
3440
|
settingsSegs = []
|
|
3441
|
+
settingsEffortHints = []
|
|
3130
3442
|
for (var m = 0; m < settingsMenus.length; m += 1) {
|
|
3131
3443
|
if (settingsMenus[m].parentNode) settingsMenus[m].parentNode.removeChild(settingsMenus[m])
|
|
3132
3444
|
}
|
|
@@ -3156,6 +3468,14 @@ window.__ModuleLoader__.load({
|
|
|
3156
3468
|
if (item) block.appendChild(buildSettingRow(item))
|
|
3157
3469
|
})
|
|
3158
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
|
+
}
|
|
3159
3479
|
section.appendChild(block)
|
|
3160
3480
|
})
|
|
3161
3481
|
settingsBody.appendChild(section)
|
|
@@ -3167,6 +3487,7 @@ window.__ModuleLoader__.load({
|
|
|
3167
3487
|
updateSettingDependencies()
|
|
3168
3488
|
syncComposerModel()
|
|
3169
3489
|
applySelectionLimit(values)
|
|
3490
|
+
paintEffortMapHint()
|
|
3170
3491
|
showSettingsTab(groups.length ? groups[0].id : '')
|
|
3171
3492
|
}
|
|
3172
3493
|
|
|
@@ -3405,7 +3726,6 @@ window.__ModuleLoader__.load({
|
|
|
3405
3726
|
if (!payload || payload.ok === false) throw new Error('恢复默认失败')
|
|
3406
3727
|
applyServerValues(payload.values, sent)
|
|
3407
3728
|
state.effort = settingsLive.chatReasoningEffort || ''
|
|
3408
|
-
writeStore(EFFORT_KEY, state.effort)
|
|
3409
3729
|
applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
|
|
3410
3730
|
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
3411
3731
|
if (bridgeOn) scheduleBridgeScan()
|
|
@@ -3437,6 +3757,9 @@ window.__ModuleLoader__.load({
|
|
|
3437
3757
|
*/
|
|
3438
3758
|
/** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
|
|
3439
3759
|
var askFocusPending = false
|
|
3760
|
+
/** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
|
|
3761
|
+
/** 调试:最后一次"没聚焦"卡在哪一步(成功时是 'ok')。自动聚焦这类问题很难从外部看出来。 */
|
|
3762
|
+
var focusBail = ''
|
|
3440
3763
|
var offSlot = null
|
|
3441
3764
|
if (ctx.slots && typeof ctx.slots.inject === 'function') {
|
|
3442
3765
|
try {
|
|
@@ -3763,18 +4086,23 @@ window.__ModuleLoader__.load({
|
|
|
3763
4086
|
|
|
3764
4087
|
function showButton(rect) {
|
|
3765
4088
|
// 浮标也在同一套配色下:它的底色是"页面背景"(浮层自身透明),按它判深浅
|
|
3766
|
-
if (!panelOpen) layer.setAttribute('data-theme',
|
|
4089
|
+
if (!panelOpen) layer.setAttribute('data-theme', currentSurfaceTheme())
|
|
3767
4090
|
// 只在"浮现"那一次播动效;已经可见时(划选范围被拖动、键盘调整)只平移,避免一直闪
|
|
3768
4091
|
if (placeFloat(button, rect)) {
|
|
3769
4092
|
setButtonPop(false)
|
|
3770
4093
|
void button.offsetWidth // 强制重排,让动画能重播
|
|
3771
4094
|
setButtonPop(true)
|
|
3772
4095
|
}
|
|
4096
|
+
// 有选区了:开始看"输入法是不是正在语音输入"(幂等,重复调用无副作用)
|
|
4097
|
+
startMicWatch()
|
|
3773
4098
|
}
|
|
3774
4099
|
|
|
3775
4100
|
function hideButton() {
|
|
3776
4101
|
button.style.display = 'none'
|
|
3777
4102
|
setButtonPop(false)
|
|
4103
|
+
// 浮标收了就没什么可等的了 —— 但语音卡开着时**不能停**:
|
|
4104
|
+
// 卡片本身就是"检测到开麦"的结果,它还要靠轮询判断"说完了没有"
|
|
4105
|
+
if (!vcardState.open) stopMicWatch()
|
|
3778
4106
|
}
|
|
3779
4107
|
|
|
3780
4108
|
/**
|
|
@@ -3806,11 +4134,1041 @@ window.__ModuleLoader__.load({
|
|
|
3806
4134
|
void quoteButton.offsetWidth
|
|
3807
4135
|
setQuotePop(true)
|
|
3808
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()
|
|
3809
5165
|
}
|
|
3810
5166
|
|
|
3811
5167
|
function hideQuoteButton() {
|
|
3812
5168
|
quoteButton.style.display = 'none'
|
|
3813
5169
|
setQuotePop(false)
|
|
5170
|
+
// 浮标收了就没什么可等的了(与 hideButton 同一条规则);语音卡开着时不能停
|
|
5171
|
+
if (!vcardState.open) stopMicWatch()
|
|
3814
5172
|
}
|
|
3815
5173
|
|
|
3816
5174
|
// ────────────────────── 引用(❝):把别处的文字挂进输入框 ──────────────────────
|
|
@@ -4106,6 +5464,14 @@ window.__ModuleLoader__.load({
|
|
|
4106
5464
|
* 用 Map 而不是数组:既要按帧查自己的 url,也要遍历找回消息来源对应的帧。
|
|
4107
5465
|
*/
|
|
4108
5466
|
var bridged = new Map()
|
|
5467
|
+
/**
|
|
5468
|
+
* 帧 → 最后一次收到它的消息的时间戳。
|
|
5469
|
+
*
|
|
5470
|
+
* 只为一件事:判断"用户此刻是不是就在那个侧边栏网页里"(见 hasFocusEvidence)。
|
|
5471
|
+
* 焦点落在不透明源子框架里时,父文档的 `document.hasFocus()` 未必为真,
|
|
5472
|
+
* 而桥报上来的每条消息本身就是"用户刚在那儿动过"的证据。
|
|
5473
|
+
*/
|
|
5474
|
+
var frameTouchedAt = new Map()
|
|
4109
5475
|
/** 总开关:host 的 bridgeSidebarPreview 说关就关(见下面 ping 分支)。 */
|
|
4110
5476
|
var bridgeOn = true
|
|
4111
5477
|
/** 扫描节流用的定时器 id。 */
|
|
@@ -4285,11 +5651,14 @@ window.__ModuleLoader__.load({
|
|
|
4285
5651
|
}
|
|
4286
5652
|
// 帧没了就不会再有 clear 消息了:把它占着的那条选区一起清掉,
|
|
4287
5653
|
// 否则 `bridgeActive` 会一直挂着(表现为顶层选区塌掉也不收浮标)。
|
|
5654
|
+
// 卡片开着时也不清:帧没了但它占着的选区仍是"这一轮的解读对象"(发送要用),
|
|
5655
|
+
// 与下面 clear 分支同一条规则。
|
|
4288
5656
|
if (state.selection && state.selection.frame === dead[i]) {
|
|
4289
|
-
state.selection = null
|
|
4290
5657
|
state.bridgeActive = false
|
|
5658
|
+
if (!vcardState.open) state.selection = null
|
|
4291
5659
|
hideButton()
|
|
4292
5660
|
}
|
|
5661
|
+
frameTouchedAt.delete(dead[i])
|
|
4293
5662
|
bridged.delete(dead[i])
|
|
4294
5663
|
}
|
|
4295
5664
|
}
|
|
@@ -4326,6 +5695,8 @@ window.__ModuleLoader__.load({
|
|
|
4326
5695
|
if (!bridgeOn || !data || data.__dshSel !== 1) return
|
|
4327
5696
|
var frame = frameBySource(event.source)
|
|
4328
5697
|
if (!frame) return
|
|
5698
|
+
// 这条消息本身就是"用户此刻正在那个网页里"的证据(见 hasFocusEvidence)
|
|
5699
|
+
frameTouchedAt.set(frame, Date.now())
|
|
4329
5700
|
if (data.kind === 'press') {
|
|
4330
5701
|
// 帧内按下:与主会话一致——先收浮标。父页面收不到帧内的 mousedown,
|
|
4331
5702
|
// 少了这一条,在网页里点一下(取消选区、点别处、点另一个网页)浮标就赖着不走。
|
|
@@ -4336,12 +5707,23 @@ window.__ModuleLoader__.load({
|
|
|
4336
5707
|
}
|
|
4337
5708
|
hideButton()
|
|
4338
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
|
+
}
|
|
4339
5717
|
return
|
|
4340
5718
|
}
|
|
4341
5719
|
if (data.kind === 'clear') {
|
|
4342
5720
|
if (state.selection && state.selection.frame === frame) {
|
|
4343
|
-
state.selection = null
|
|
4344
5721
|
state.bridgeActive = false
|
|
5722
|
+
// ⚠️ 卡片开着时**不清选区**:抢焦点必然让帧内选区塌掉(输入法上屏要的正是焦点),
|
|
5723
|
+
// 桥于是报 clear —— 但用户选的那段文字仍然是这一轮的解读对象(发送时要用它,
|
|
5724
|
+
// 麦克风轮询也要靠 state.selection 判断"说完了没有")。
|
|
5725
|
+
// 本文档那条路是一致的:顶层选区被抢焦点搞塌时也只收浮标,不清 state.selection。
|
|
5726
|
+
if (!vcardState.open) state.selection = null
|
|
4345
5727
|
hideButton()
|
|
4346
5728
|
hideQuoteButton()
|
|
4347
5729
|
}
|
|
@@ -4372,6 +5754,17 @@ window.__ModuleLoader__.load({
|
|
|
4372
5754
|
label: labelForFrame(frame, data.sel.label),
|
|
4373
5755
|
}
|
|
4374
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
|
+
}
|
|
4375
5768
|
if (panelOpen) {
|
|
4376
5769
|
// 小窗开着:帧内这条选区也只是"一段可以引用的文字"(上下文由帧内的桥给)
|
|
4377
5770
|
state.quoteSelection = {
|
|
@@ -4561,7 +5954,10 @@ window.__ModuleLoader__.load({
|
|
|
4561
5954
|
* 打开面板的公共路径:真实选区与自检钩子**共用**这一条,
|
|
4562
5955
|
* 否则钩子会绕过缓存(缓存行为只能靠人工点选才能验证)。
|
|
4563
5956
|
*/
|
|
4564
|
-
function
|
|
5957
|
+
function openPanelWithInner(text, context, label, anchor, keyContext, options) {
|
|
5958
|
+
// 这一轮是不是"从语音卡直接进追问":paint() 靠它决定不画翻译/详解两节、
|
|
5959
|
+
// 并且让追问输入框直接可见(见 state.voiceAsk 的几处用法)
|
|
5960
|
+
state.voiceAsk = !!(options && options.askFirst)
|
|
4565
5961
|
// 记下"这次划词发生在哪个会话"——小窗后续所有按会话解析的东西都用它
|
|
4566
5962
|
panelSessionId = currentSessionId()
|
|
4567
5963
|
state.payload = buildPayload(text, context, label)
|
|
@@ -4575,11 +5971,39 @@ window.__ModuleLoader__.load({
|
|
|
4575
5971
|
var cached = cache.get(cacheKey)
|
|
4576
5972
|
state.cacheKey = cacheKey
|
|
4577
5973
|
state.turns = []
|
|
4578
|
-
// 换了一段选中文字 =
|
|
5974
|
+
// 换了一段选中文字 = 换了一次对话:上一段攒的引用**和检索结果**都不该跟过来。
|
|
5975
|
+
//
|
|
5976
|
+
// ⚠️ toolDigest 原来漏在这一步(属遗漏,不是有意保留)。它的语义单位就是"一次对话":
|
|
5977
|
+
// 按历史条目落盘、按条目恢复,宿主发给模型的措辞也是「本会话已经查到的结果」。
|
|
5978
|
+
// 但 state 是整页只建一次的,而它**只在历史回放时被恢复、别处从不复位** ——
|
|
5979
|
+
// 于是只要某段对话搜过东西(比如问过一次新闻),之后**每一段新选区**的追问
|
|
5980
|
+
// 都会把那段结果一并带给模型(用户实测:"每次后面都带着我之前搜的新闻")。
|
|
5981
|
+
//
|
|
5982
|
+
// 只清 digest、不动 tools/thought:那几个在请求开始处另有一份复位,
|
|
5983
|
+
// digest 则该在这里(以及历史回放)动。
|
|
5984
|
+
state.toolDigest = ''
|
|
4579
5985
|
clearQuotes()
|
|
4580
5986
|
state.parts = (cached && cached.parts) || { translation: '', detail: '' }
|
|
4581
5987
|
state.stage = ''
|
|
4582
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
|
+
}
|
|
4583
6007
|
renderTurns()
|
|
4584
6008
|
if (cached) {
|
|
4585
6009
|
state.turns = (cached.turns || []).slice()
|
|
@@ -4607,6 +6031,24 @@ window.__ModuleLoader__.load({
|
|
|
4607
6031
|
runRequest(state.payload, cacheKey)
|
|
4608
6032
|
})
|
|
4609
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
|
+
}
|
|
4610
6052
|
/** 由 Range 还原一个 selection 形状的对象(collectContext 只用到这两个方法)。 */
|
|
4611
6053
|
function rangeSelection(range) {
|
|
4612
6054
|
return {
|
|
@@ -5365,10 +6807,32 @@ window.__ModuleLoader__.load({
|
|
|
5365
6807
|
panel.setAttribute('data-resized', '1')
|
|
5366
6808
|
}
|
|
5367
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
|
+
|
|
5368
6833
|
/** 当前尺寸(读实际渲染值,比拿变量算更稳 —— 拖拽过程中变量会漂)。 */
|
|
5369
6834
|
function currentPanelSize() {
|
|
5370
|
-
|
|
5371
|
-
return { w: rect.width, h: rect.height }
|
|
6835
|
+
return { w: panel.offsetWidth, h: panel.offsetHeight }
|
|
5372
6836
|
}
|
|
5373
6837
|
|
|
5374
6838
|
/** 夹进视口:改大小/窗口变化后都调,保证手柄还在屏幕里、小窗不会超出可视区。 */
|
|
@@ -5389,7 +6853,7 @@ window.__ModuleLoader__.load({
|
|
|
5389
6853
|
* 优先保住左上角(那是用户看到的锚点),右边/下边放不下才往回收。
|
|
5390
6854
|
*/
|
|
5391
6855
|
function clampPanelPosition() {
|
|
5392
|
-
var rect =
|
|
6856
|
+
var rect = panelLayoutRect()
|
|
5393
6857
|
var maxLeft = Math.max(4, window.innerWidth - rect.width - 4)
|
|
5394
6858
|
var maxTop = Math.max(4, window.innerHeight - rect.height - 4)
|
|
5395
6859
|
panel.style.left = Math.round(Math.min(Math.max(rect.left, 4), maxLeft)) + 'px'
|
|
@@ -5491,6 +6955,12 @@ window.__ModuleLoader__.load({
|
|
|
5491
6955
|
changed = Math.abs(clamped.w - startSize.w) + Math.abs(clamped.h - startSize.h)
|
|
5492
6956
|
panel.style.width = Math.round(clamped.w) + 'px'
|
|
5493
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'
|
|
5494
6964
|
panelSize = clamped
|
|
5495
6965
|
panel.setAttribute('data-resized', '1')
|
|
5496
6966
|
if (historyList.style.display === 'flex') placeHistoryList()
|
|
@@ -5558,6 +7028,64 @@ window.__ModuleLoader__.load({
|
|
|
5558
7028
|
warmModelCatalog()
|
|
5559
7029
|
}
|
|
5560
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
|
+
|
|
5561
7089
|
function closePanel() {
|
|
5562
7090
|
closeModelMenu()
|
|
5563
7091
|
// 小窗关掉时设置抽屉也一起收:否则下次开窗会直接停在设置页上
|
|
@@ -5566,6 +7094,20 @@ window.__ModuleLoader__.load({
|
|
|
5566
7094
|
closeSettings()
|
|
5567
7095
|
panelOpen = false
|
|
5568
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
|
|
5569
7111
|
hideHistoryList()
|
|
5570
7112
|
// 小窗收起来了:引用浮标跟着走(它只在"小窗开着"时有意义)
|
|
5571
7113
|
hideQuoteButton()
|
|
@@ -5607,7 +7149,8 @@ window.__ModuleLoader__.load({
|
|
|
5607
7149
|
for (var i = 0; i < state.turns.length; i += 1) {
|
|
5608
7150
|
if (state.turns[i].role === 'user') asked = true
|
|
5609
7151
|
}
|
|
5610
|
-
|
|
7152
|
+
// 语音卡那条路没有解读可展开,别摆一个点了没反应的按钮
|
|
7153
|
+
expandButton.style.display = !hasDetail && translationReady && !asked && !state.voiceAsk ? '' : 'none'
|
|
5611
7154
|
expandButton.disabled = false
|
|
5612
7155
|
return asked
|
|
5613
7156
|
}
|
|
@@ -5635,6 +7178,9 @@ window.__ModuleLoader__.load({
|
|
|
5635
7178
|
var stage = state.stage
|
|
5636
7179
|
var selected = state.payload && state.payload.text ? state.payload.text : state.selection ? state.selection.text : ''
|
|
5637
7180
|
var sections = splitSections(visibleRaw())
|
|
7181
|
+
// 语音卡那条路(选中文字 + 输入法语音 → 直接追问):这一轮**没有解读**,
|
|
7182
|
+
// 所以翻译节整节不画 —— 不是"画个空节、里面写这次没有返回内容"(用户否掉的正是它)。
|
|
7183
|
+
translationSection.root.style.display = state.voiceAsk ? 'none' : ''
|
|
5638
7184
|
|
|
5639
7185
|
// 翻译节:首轮流式期间逐字渲染;已有结果就用结果;一个字都还没有时给等待特效
|
|
5640
7186
|
var live = stripEchoPrefix(stage === 'detail' ? '' : sections.translation || sections.other, selected)
|
|
@@ -5651,6 +7197,7 @@ window.__ModuleLoader__.load({
|
|
|
5651
7197
|
// 创建时 start 事件还没到,state.effort 是空的;用函数实时读,档位才不会显示成兜底值
|
|
5652
7198
|
effortOf: function () { return state.stageEffort || 'low' },
|
|
5653
7199
|
thoughtOf: function () { return state.thought },
|
|
7200
|
+
noticeOf: function () { return state.notice },
|
|
5654
7201
|
})
|
|
5655
7202
|
} else if (state.phase === 'error') {
|
|
5656
7203
|
translationSection.content.textContent = ''
|
|
@@ -5670,19 +7217,6 @@ window.__ModuleLoader__.load({
|
|
|
5670
7217
|
retryButton.style.display = ''
|
|
5671
7218
|
}
|
|
5672
7219
|
|
|
5673
|
-
// 伪造的工具调用被剥掉时,正文下面补一句交代(不是没查,是这轮没工具可用)
|
|
5674
|
-
if (state.phase === 'done' && state.toolResidue) {
|
|
5675
|
-
var hasNote = false
|
|
5676
|
-
for (var ni = 0; ni < translationSection.content.children.length; ni += 1) {
|
|
5677
|
-
if (translationSection.content.children[ni].className === 'dsh-sel-note') hasNote = true
|
|
5678
|
-
}
|
|
5679
|
-
if (!hasNote) {
|
|
5680
|
-
translationSection.content.appendChild(
|
|
5681
|
-
el('div', 'dsh-sel-note', '(本轮没有可用工具:模型输出的工具调用格式已忽略,以上是它凭已有知识的回答)'),
|
|
5682
|
-
)
|
|
5683
|
-
}
|
|
5684
|
-
}
|
|
5685
|
-
|
|
5686
7220
|
// 详解节:只在第二阶段出现(或已缓存过)
|
|
5687
7221
|
var wantDetail = !!(state.parts.detail || stage === 'detail')
|
|
5688
7222
|
detailSection.root.style.display = wantDetail ? '' : 'none'
|
|
@@ -5696,6 +7230,7 @@ window.__ModuleLoader__.load({
|
|
|
5696
7230
|
effort: state.effort,
|
|
5697
7231
|
effortOf: function () { return state.stageEffort || 'high' },
|
|
5698
7232
|
thoughtOf: function () { return state.thought },
|
|
7233
|
+
noticeOf: function () { return state.notice },
|
|
5699
7234
|
})
|
|
5700
7235
|
}
|
|
5701
7236
|
|
|
@@ -5707,9 +7242,10 @@ window.__ModuleLoader__.load({
|
|
|
5707
7242
|
var translationReady = state.phase === 'done' && !!state.parts.translation
|
|
5708
7243
|
var wide = syncPanelStage()
|
|
5709
7244
|
// 配色按**面板实际底色**选(不看系统偏好):主题实现方式怎么变都不影响
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
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'
|
|
5713
7249
|
// 展开 CTA:翻译还在跑(含思考期)时先不出现——那时候该看的是等待特效,
|
|
5714
7250
|
// 摆在下面只会是个灰着的按钮,反而像"没反应";发过追问之后也收起(见 syncExpandCta)。
|
|
5715
7251
|
syncExpandCta()
|
|
@@ -5769,26 +7305,33 @@ window.__ModuleLoader__.load({
|
|
|
5769
7305
|
* · preventScroll:别因为聚焦把页面滚一下(划完词页面跳走最难受)。
|
|
5770
7306
|
*/
|
|
5771
7307
|
function focusComposerOnce() {
|
|
7308
|
+
focusBail = 'pending=' + String(askFocusPending) + ' panel=' + String(panelOpen)
|
|
5772
7309
|
if (!askFocusPending || !panelOpen) return
|
|
7310
|
+
focusBail = 'composerVisible=' + String(composerVisible()) + ' panelSel=' + String(hasPanelSelection())
|
|
5773
7311
|
if (!composerVisible()) return
|
|
5774
7312
|
// 小窗里正有一段选中文字(用户正在划词、准备点「❝ 引用」):**这一下聚焦会把选区清掉**。
|
|
5775
7313
|
// 让位给用户,并且不再补焦 —— 引用浮标是这个功能的主入口,不能因为抢焦点而让它消失。
|
|
5776
7314
|
//(实测:开窗后那一帧恰好落在"首轮刚出完"的自动聚焦上,划好的词会瞬间没了。)
|
|
5777
7315
|
if (hasPanelSelection()) {
|
|
7316
|
+
focusBail = '让位给小窗里的选区'
|
|
5778
7317
|
askFocusPending = false
|
|
5779
7318
|
return
|
|
5780
7319
|
}
|
|
5781
7320
|
var active = document.activeElement
|
|
5782
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))
|
|
5783
7323
|
// 只有"人家正在别处打字"才值得让路。
|
|
5784
7324
|
// 注意别把普通的按钮焦点也算进去:点浮标时焦点正好落在浮标那个 <button> 上
|
|
5785
7325
|
//(Chrome 点按钮会给它焦点),照"非 body 就不抢"的老写法会把我们自己挡在门外 ——
|
|
5786
7326
|
// 这正是"开窗后没有自动聚焦"的原因。
|
|
5787
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('>') })() + ')'
|
|
5788
7330
|
askFocusPending = false // 人家真的在别处打字,这次开窗就不再抢
|
|
5789
7331
|
return
|
|
5790
7332
|
}
|
|
5791
7333
|
askFocusPending = false
|
|
7334
|
+
focusBail = 'ok'
|
|
5792
7335
|
focusAskBox()
|
|
5793
7336
|
}
|
|
5794
7337
|
|
|
@@ -5808,12 +7351,45 @@ window.__ModuleLoader__.load({
|
|
|
5808
7351
|
function isTypingTarget(node) {
|
|
5809
7352
|
if (!node || node === askBox) return false
|
|
5810
7353
|
var tag = String(node.tagName || '').toLowerCase()
|
|
5811
|
-
|
|
5812
|
-
|
|
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
|
|
5813
7361
|
}
|
|
5814
7362
|
|
|
5815
|
-
/**
|
|
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
|
+
*/
|
|
5816
7387
|
function focusAskBox() {
|
|
7388
|
+
try {
|
|
7389
|
+
askBox.blur()
|
|
7390
|
+
} catch (error) {
|
|
7391
|
+
/* 桩环境 */
|
|
7392
|
+
}
|
|
5817
7393
|
try {
|
|
5818
7394
|
askBox.focus({ preventScroll: true })
|
|
5819
7395
|
} catch (error) {
|
|
@@ -5841,7 +7417,7 @@ window.__ModuleLoader__.load({
|
|
|
5841
7417
|
/** 内容变高后把面板拉回视口内。 */
|
|
5842
7418
|
function keepInsideViewport() {
|
|
5843
7419
|
if (!panelOpen) return
|
|
5844
|
-
var rect =
|
|
7420
|
+
var rect = panelLayoutRect()
|
|
5845
7421
|
var width = Math.min(rect.width, window.innerWidth - 12)
|
|
5846
7422
|
var left = clamp(rect.left, 6, Math.max(6, window.innerWidth - width - 6))
|
|
5847
7423
|
var top = clamp(rect.top, 6, Math.max(6, window.innerHeight - 64))
|
|
@@ -6205,46 +7781,58 @@ window.__ModuleLoader__.load({
|
|
|
6205
7781
|
return folded
|
|
6206
7782
|
}
|
|
6207
7783
|
|
|
6208
|
-
function sanitizeToolResidue(text) {
|
|
6209
|
-
var out = String(text || '')
|
|
6210
|
-
out = out.replace(/<ds_safety_tool_call>[\s\S]*?<\/ds_safety_tool_call>\s*/gi, '')
|
|
6211
|
-
out = out.replace(/<ds_safety_tool_call>[\s\S]*$/i, '')
|
|
6212
|
-
out = out.replace(/<\/?ds_safety_tool_call>\s*/gi, '')
|
|
6213
|
-
// DeepSeek 原生 DSML 工具调用(全角竖线 U+FF5C 包起来的 tool_calls/invoke/parameter)。
|
|
6214
|
-
// host 侧已经在流里滤过一道,这里兜住"旧缓存 / 历史回放 / 升格"这些不经过 host 的路径。
|
|
6215
|
-
// 形态不止一种:marker 与标签名之间**可能有空格**(实测),且可能是逐标签式
|
|
6216
|
-
// (只有 <MARK parameter …> 而没有 tool_calls 包裹)——按"标签栈"整段丢掉最稳。
|
|
6217
|
-
var dsml = '\uFF5C\uFF5CDSML\uFF5C\uFF5C'
|
|
6218
|
-
var tag = new RegExp('</?\\s*' + dsml + '\\s*(?:tool_calls|calls|invoke|parameter)\\b[^>]*>', 'g')
|
|
6219
|
-
out = stripTaggedBlocks(out, tag)
|
|
6220
|
-
out = stripTaggedBlocks(out, /<\/?\s*ds_safety_tool_call\s*>/gi)
|
|
6221
|
-
return out
|
|
6222
|
-
}
|
|
6223
7784
|
|
|
6224
|
-
/**
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
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'
|
|
6235
7814
|
}
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
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'
|
|
6239
7825
|
}
|
|
6240
|
-
return out
|
|
6241
7826
|
}
|
|
6242
7827
|
|
|
6243
|
-
/**
|
|
7828
|
+
/** 当前可见的原文。
|
|
7829
|
+
*
|
|
7830
|
+
* 以前这里会剥掉"伪造的工具调用"文本(DSML / ds_safety_tool_call),
|
|
7831
|
+
* 现在**不再做任何文本清洗** —— 照主会话的做法:工具每轮都在请求里,
|
|
7832
|
+
* 模型走协议层表达调用,正文里根本不该出现这种标记。
|
|
7833
|
+
* 清洗留在那儿只会再吃一次正文(未闭合就删到结尾)。 */
|
|
6244
7834
|
function visibleRaw() {
|
|
6245
|
-
|
|
6246
|
-
if (cleaned !== state.raw) state.toolResidue = true
|
|
6247
|
-
return cleaned
|
|
7835
|
+
return state.raw
|
|
6248
7836
|
}
|
|
6249
7837
|
|
|
6250
7838
|
// ────────────────────── 小窗对话历史(A′) ──────────────────────
|
|
@@ -6303,6 +7891,8 @@ window.__ModuleLoader__.load({
|
|
|
6303
7891
|
state.raw = ''
|
|
6304
7892
|
state.phase = 'done'
|
|
6305
7893
|
state.error = ''
|
|
7894
|
+
// 打开历史/命中缓存也是"已完成"状态 → 光标交回输入框
|
|
7895
|
+
askFocusAfterTurn()
|
|
6306
7896
|
state.sharedCache = false
|
|
6307
7897
|
state.fromCache = source === 'local'
|
|
6308
7898
|
state.fromHistory = source !== 'local'
|
|
@@ -6371,7 +7961,7 @@ window.__ModuleLoader__.load({
|
|
|
6371
7961
|
* 高度跟面板对齐(上限视口高度),自带滚动条。
|
|
6372
7962
|
*/
|
|
6373
7963
|
function placeHistoryList() {
|
|
6374
|
-
var rect =
|
|
7964
|
+
var rect = panelLayoutRect()
|
|
6375
7965
|
var width = 250
|
|
6376
7966
|
var gap = 10
|
|
6377
7967
|
var viewportW = window.innerWidth || 1200
|
|
@@ -6588,6 +8178,28 @@ window.__ModuleLoader__.load({
|
|
|
6588
8178
|
})
|
|
6589
8179
|
}
|
|
6590
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
|
+
|
|
6591
8203
|
/** 工具名 → 中文标签(只用于内部摘要,不上界面)。 */
|
|
6592
8204
|
function toolLabel(name) {
|
|
6593
8205
|
// 小窗默认用的是 free-search 插件的工具(见 host 侧 toolNames 注释)
|
|
@@ -6688,6 +8300,51 @@ window.__ModuleLoader__.load({
|
|
|
6688
8300
|
}
|
|
6689
8301
|
}
|
|
6690
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
|
+
|
|
6691
8348
|
/** 滚轮/触摸:用户自己滚 = 立刻收回跟随权;滚回底部再交还。 */
|
|
6692
8349
|
function noteUserScroll(event) {
|
|
6693
8350
|
var up = event && typeof event.deltaY === 'number' && event.deltaY < 0
|
|
@@ -6766,7 +8423,7 @@ window.__ModuleLoader__.load({
|
|
|
6766
8423
|
if (node.__sig !== sig) {
|
|
6767
8424
|
// 内容没变就别重建:重建会把预览 iframe 一起换掉(多建一次 = 白闪一次)
|
|
6768
8425
|
node.__sig = sig
|
|
6769
|
-
renderRich(node,
|
|
8426
|
+
renderRich(node, turn.text || '…', { settled: turn.streaming !== true })
|
|
6770
8427
|
}
|
|
6771
8428
|
paintNotice(node, turn)
|
|
6772
8429
|
paintQuoteAll(node, turn)
|
|
@@ -6829,7 +8486,7 @@ window.__ModuleLoader__.load({
|
|
|
6829
8486
|
bubble.className += ' dsh-sel-bubble-err'
|
|
6830
8487
|
bubble.textContent = turn.text
|
|
6831
8488
|
} else {
|
|
6832
|
-
renderRich(bubble,
|
|
8489
|
+
renderRich(bubble, turn.text || '…', { settled: turn.streaming !== true })
|
|
6833
8490
|
}
|
|
6834
8491
|
if (turn.role !== 'user') {
|
|
6835
8492
|
paintNotice(bubble, turn)
|
|
@@ -6869,7 +8526,7 @@ window.__ModuleLoader__.load({
|
|
|
6869
8526
|
event.stopPropagation()
|
|
6870
8527
|
var source = existing.__turn
|
|
6871
8528
|
if (!source) return
|
|
6872
|
-
var body = foldForHistory(
|
|
8529
|
+
var body = foldForHistory(source.text)
|
|
6873
8530
|
// 整条引用同样不带上下文:出处写进标签(「小窗第 N 轮回答」),对话本身在历史里
|
|
6874
8531
|
addQuote(body, turnLabelOf(source), { context: '', session: false })
|
|
6875
8532
|
})
|
|
@@ -6878,7 +8535,7 @@ window.__ModuleLoader__.load({
|
|
|
6878
8535
|
|
|
6879
8536
|
/** 把首轮解读结果作为追问的第一条助手上下文(截断,避免过长)。 */
|
|
6880
8537
|
function seedHistoryFromExplanation() {
|
|
6881
|
-
var fallback = splitSections(
|
|
8538
|
+
var fallback = splitSections(state.raw)
|
|
6882
8539
|
var translation = (state.parts.translation || fallback.translation || '').trim()
|
|
6883
8540
|
var detail = (state.parts.detail || fallback.detail || '').trim()
|
|
6884
8541
|
// 写成"前情提要"而不是"我上一轮的回答":否则模型会顺着把两节内容再复述一遍
|
|
@@ -6999,6 +8656,17 @@ window.__ModuleLoader__.load({
|
|
|
6999
8656
|
setStatus('正在取引用的上下文…')
|
|
7000
8657
|
return
|
|
7001
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
|
+
}
|
|
7002
8670
|
// 引用是"下一条提问"的:立刻清空(提问已经在手上,别让下一轮又带上)
|
|
7003
8671
|
if (quotes.length > 0) {
|
|
7004
8672
|
state.quotes = []
|
|
@@ -7089,6 +8757,8 @@ window.__ModuleLoader__.load({
|
|
|
7089
8757
|
clearInterval(timer)
|
|
7090
8758
|
state.asking = false
|
|
7091
8759
|
state.toolBusy = false
|
|
8760
|
+
// 本轮结束了:"正在整理结论…"这类过程提示一律撤掉(结论轮一个字没吐时也别留着)
|
|
8761
|
+
clearTransientNotice(answerTurn)
|
|
7092
8762
|
answerTurn.asking = false
|
|
7093
8763
|
answerTurn.streaming = false
|
|
7094
8764
|
clearWaiting(chatLog)
|
|
@@ -7112,6 +8782,10 @@ window.__ModuleLoader__.load({
|
|
|
7112
8782
|
? ''
|
|
7113
8783
|
: '追问完成 · 耗时 ' + ((Date.now() - startedAt) / 1000).toFixed(1) + 's',
|
|
7114
8784
|
)
|
|
8785
|
+
// 一轮**追问**结束(成功 / 失败 / 被停止)—— 与小窗其它轮同一条规则:光标交回输入框。
|
|
8786
|
+
// ⚠️ 这里原来是漏的:首轮的 succeed/fail 有规则,追问这条路径没有,
|
|
8787
|
+
// 所以"追问失败"之后输入框里不出现光标(用户实测)。
|
|
8788
|
+
askFocusAfterTurn()
|
|
7115
8789
|
}
|
|
7116
8790
|
|
|
7117
8791
|
fetch(API, {
|
|
@@ -7124,7 +8798,8 @@ window.__ModuleLoader__.load({
|
|
|
7124
8798
|
sessionId: panelOrCurrentSessionId(),
|
|
7125
8799
|
question: sent,
|
|
7126
8800
|
history: history,
|
|
7127
|
-
|
|
8801
|
+
// 网页输出要跟着当前深浅走:深色 Harness 里给一张白底页面会很刺眼
|
|
8802
|
+
...(state.webAnswer ? { webAnswer: true, theme: currentSurfaceTheme() } : {}),
|
|
7128
8803
|
...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
|
|
7129
8804
|
...(state.effort ? { effort: state.effort } : {}),
|
|
7130
8805
|
...(state.toolDigest ? { toolDigest: state.toolDigest } : {}),
|
|
@@ -7159,6 +8834,8 @@ window.__ModuleLoader__.load({
|
|
|
7159
8834
|
continue
|
|
7160
8835
|
}
|
|
7161
8836
|
if (message.type === 'delta') {
|
|
8837
|
+
// 正文开始到达 → 撤掉"正在整理结论…"这类过程提示(以前它永远挂着)
|
|
8838
|
+
clearTransientNotice(answerTurn)
|
|
7162
8839
|
if (!answerTurn.text.trim()) answerTurn.asking = false
|
|
7163
8840
|
answerTurn.text += message.text
|
|
7164
8841
|
scheduleTurnsRender()
|
|
@@ -7195,15 +8872,16 @@ window.__ModuleLoader__.load({
|
|
|
7195
8872
|
// host 发现档位被上游拒绝(400/401/500)会自动去掉档位重试,这里如实告诉用户,
|
|
7196
8873
|
// 并把"这个模型不吃这个档位"记下来 —— 下次不再拿同一个组合去撞墙
|
|
7197
8874
|
answerTurn.notice = String(message.text || '')
|
|
8875
|
+
// 记下 code:'conclusion-retry' 是"进行中"状态,正文到了/本轮结束就该撤
|
|
8876
|
+
answerTurn.noticeCode = String(message.code || '')
|
|
7198
8877
|
if ((message.code === 'effort-rejected' || message.code === 'reasoning-leak') && message.tier) {
|
|
8878
|
+
// 记下"这个模型的这一档被拒 / 会泄漏",菜单里给它划一道斜线(下次不再撞)
|
|
7199
8879
|
rememberBadTier(message.tier, message.code === 'reasoning-leak' ? 'leak' : 'rejected')
|
|
7200
|
-
//
|
|
7201
|
-
|
|
7202
|
-
writeStore(EFFORT_KEY, '')
|
|
8880
|
+
// **不动**用户选的那一格:设置页与菜单读的就是它(唯一一份数据),
|
|
8881
|
+
// 悄悄清掉会让人以为自己没选过;这一轮该发什么由 host 的换档重试兜。
|
|
7203
8882
|
paintModelPill()
|
|
7204
8883
|
}
|
|
7205
|
-
scheduleTurnsRender()
|
|
7206
|
-
} else if (message.type === 'error') {
|
|
8884
|
+
scheduleTurnsRender() } else if (message.type === 'error') {
|
|
7207
8885
|
finish(message.message || '模型返回错误')
|
|
7208
8886
|
}
|
|
7209
8887
|
}
|
|
@@ -7316,6 +8994,7 @@ window.__ModuleLoader__.load({
|
|
|
7316
8994
|
entry.effort = opts.effort || ''
|
|
7317
8995
|
entry.effortOf = opts.effortOf || null
|
|
7318
8996
|
entry.thoughtOf = opts.thoughtOf || null
|
|
8997
|
+
entry.noticeOf = opts.noticeOf || null
|
|
7319
8998
|
entry.busyOf = opts.busyOf || toolBusyNow
|
|
7320
8999
|
updateWaitClocks()
|
|
7321
9000
|
return entry
|
|
@@ -7347,9 +9026,14 @@ window.__ModuleLoader__.load({
|
|
|
7347
9026
|
if (!entry.hint) continue
|
|
7348
9027
|
var thinking = entry.thoughtOf ? String(entry.thoughtOf() || '') : ''
|
|
7349
9028
|
thinking = thinking.replace(/\s+/g, ' ').trim()
|
|
9029
|
+
var notice = entry.noticeOf ? String(entry.noticeOf() || '') : ''
|
|
9030
|
+
notice = notice.replace(/\s+/g, ' ').trim()
|
|
7350
9031
|
if (entry.busyOf && entry.busyOf()) {
|
|
7351
9032
|
// 检索阶段:只说"在查资料"。查询词/命中/链接都不进小窗(那是模型的原料,不是结果)
|
|
7352
9033
|
entry.hint.textContent = '🔍 正在检索资料…'
|
|
9034
|
+
} else if (notice) {
|
|
9035
|
+
// host 的"正在换档重试 / 上游偶发错误"比思考尾巴更该让人看见(它解释了这段时间去哪了)
|
|
9036
|
+
entry.hint.textContent = '↻ ' + notice.slice(-72)
|
|
7353
9037
|
} else if (thinking) {
|
|
7354
9038
|
// 看得见它在想什么,就不会以为"没反应"了
|
|
7355
9039
|
entry.hint.textContent = '💭 ' + thinking.slice(-72)
|
|
@@ -7384,8 +9068,8 @@ window.__ModuleLoader__.load({
|
|
|
7384
9068
|
state.aborted = false
|
|
7385
9069
|
state.stageEffort = ''
|
|
7386
9070
|
state.thought = ''
|
|
9071
|
+
state.notice = ''
|
|
7387
9072
|
state.tools = []
|
|
7388
|
-
state.toolResidue = false
|
|
7389
9073
|
state.startedAt = Date.now()
|
|
7390
9074
|
state.elapsed = 0
|
|
7391
9075
|
paint()
|
|
@@ -7409,6 +9093,8 @@ window.__ModuleLoader__.load({
|
|
|
7409
9093
|
// 用户收起小窗导致的中止:不算失败,记成"已停止",重开还能接着看
|
|
7410
9094
|
state.phase = state.aborted ? 'paused' : 'error'
|
|
7411
9095
|
state.error = state.aborted ? '' : message
|
|
9096
|
+
// 一轮失败/中止结束 → 同样把光标交回输入框,用户可以直接重试或换个问法
|
|
9097
|
+
askFocusAfterTurn()
|
|
7412
9098
|
state.toolBusy = false
|
|
7413
9099
|
state.elapsed = Date.now() - state.startedAt
|
|
7414
9100
|
stopTicker()
|
|
@@ -7419,6 +9105,8 @@ window.__ModuleLoader__.load({
|
|
|
7419
9105
|
|
|
7420
9106
|
function succeed() {
|
|
7421
9107
|
state.phase = 'done'
|
|
9108
|
+
// 一轮成功结束 → 光标交回输入框(小窗自己的规则,见 askFocusAfterTurn)
|
|
9109
|
+
askFocusAfterTurn()
|
|
7422
9110
|
// 按内容归并(stage 只决定"请求哪一节",不决定结果落到哪个格子):
|
|
7423
9111
|
// 模型偶尔会两节都写,或没写标题,这里都要接住
|
|
7424
9112
|
var parsed = splitSections(visibleRaw())
|
|
@@ -7472,7 +9160,13 @@ window.__ModuleLoader__.load({
|
|
|
7472
9160
|
} else if (message.type === 'delta') {
|
|
7473
9161
|
state.raw += message.text
|
|
7474
9162
|
state.phase = 'streaming'
|
|
9163
|
+
// 正文到了 = 那次换档重试成功了,等待提示里那句"已自动重试"不必再挂着
|
|
9164
|
+
state.notice = ''
|
|
7475
9165
|
dirty = true
|
|
9166
|
+
} else if (message.type === 'notice') {
|
|
9167
|
+
// host 的"正在换档重试 / 上游偶发错误":以前这条分支不存在,全被丢掉。
|
|
9168
|
+
// 不 schedulePaint:由 200ms 的 ticker 写字,和 thought 同一条路。
|
|
9169
|
+
state.notice = String(message.text || '')
|
|
7476
9170
|
} else if (message.type === 'thought') {
|
|
7477
9171
|
// 只留尾巴:等待提示里显示"正在想什么",不给正文添乱;
|
|
7478
9172
|
// 刻意不 schedulePaint —— 由 200ms 的 ticker 写字,思考流不会把界面刷爆
|
|
@@ -7520,6 +9214,11 @@ window.__ModuleLoader__.load({
|
|
|
7520
9214
|
state.chars = state.raw.length
|
|
7521
9215
|
}
|
|
7522
9216
|
paint()
|
|
9217
|
+
// 复核放在**绘制之后**:内容这时候才在 DOM 里,面板这一帧长高的量当场就能夹住
|
|
9218
|
+
//(放在 paint 开头会晚一帧——那帧量到的还是旧高度,实测过)。
|
|
9219
|
+
// 位置只在开窗/拖拽/窗口 resize 时夹过,内容长高这条路径原先没人管 ——
|
|
9220
|
+
// 面板是 position:fixed、页面不滚,顶出去那截(回复框)就点不到了。
|
|
9221
|
+
ensurePanelInsideViewportNow()
|
|
7523
9222
|
})
|
|
7524
9223
|
}
|
|
7525
9224
|
// 流式期间限速(同 scheduleTurnsRender):长内容每帧整块重建会把小窗拖卡
|
|
@@ -7598,8 +9297,8 @@ window.__ModuleLoader__.load({
|
|
|
7598
9297
|
// 用分阶段保存的结果:展开详解后 state.raw 里只有详解那一段,
|
|
7599
9298
|
// 直接拿 raw 解析会导致翻译丢失(升格出来的会话里翻译变成「未记录」)
|
|
7600
9299
|
var sections = {
|
|
7601
|
-
translation: state.parts.translation || splitSections(
|
|
7602
|
-
detail: state.parts.detail || splitSections(
|
|
9300
|
+
translation: state.parts.translation || splitSections(state.raw).translation,
|
|
9301
|
+
detail: state.parts.detail || splitSections(state.raw).detail,
|
|
7603
9302
|
}
|
|
7604
9303
|
state.promoting = true
|
|
7605
9304
|
promoteButton.disabled = true
|
|
@@ -7745,6 +9444,21 @@ window.__ModuleLoader__.load({
|
|
|
7745
9444
|
scheduleCheck()
|
|
7746
9445
|
}, true)
|
|
7747
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
|
+
|
|
7748
9462
|
/**
|
|
7749
9463
|
* 浮标亮着的时候,隔几拍确认一次"那段文字还在不在"。
|
|
7750
9464
|
*
|
|
@@ -7850,9 +9564,8 @@ window.__ModuleLoader__.load({
|
|
|
7850
9564
|
// —— 悬浮状态胶囊:点一下回到最近一次小窗 ——
|
|
7851
9565
|
// —— 模型 + 推理等级(追问档) ——
|
|
7852
9566
|
var MODEL_KEY = 'dsh-selection-explain:model' // 存 "provider\tmodel"
|
|
7853
|
-
|
|
7854
|
-
var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '
|
|
7855
|
-
var FALLBACK_TIERS = ['off', 'low', 'high', 'max']
|
|
9567
|
+
/** 四个格子的中文名(设置页与小窗共用一套词;`medium` 只在标注模型自己的档位时出现)。 */
|
|
9568
|
+
var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '最高' }
|
|
7856
9569
|
/**
|
|
7857
9570
|
* 模型清单缓存。
|
|
7858
9571
|
*
|
|
@@ -8012,6 +9725,11 @@ window.__ModuleLoader__.load({
|
|
|
8012
9725
|
modelPillTier.textContent = tier ? '· ' + tierLabel(tier) : ''
|
|
8013
9726
|
modelPill.title = (provider ? provider + ' · ' : '') + name + (tier ? ' · 推理等级 ' + tierLabel(tier) : '') +
|
|
8014
9727
|
'(模型与追问思考强度)'
|
|
9728
|
+
// 胶囊是"当前模型"的**唯一**统一落点:换模型的所有路径最后都会走到这里
|
|
9729
|
+
//(设置抽屉的模型下拉、小窗的模型菜单、跟随主会话时主界面换模型…),
|
|
9730
|
+
// 设置页那行"四格 → 真实档位"也在这里跟着重画 —— 早先只在 chooseSharedModel 里画,
|
|
9731
|
+
// 于是"从抽屉的下拉里换模型"那行纹丝不动(用户报的正是这个)。
|
|
9732
|
+
paintEffortMapHint()
|
|
8015
9733
|
}
|
|
8016
9734
|
|
|
8017
9735
|
/**
|
|
@@ -8133,44 +9851,51 @@ window.__ModuleLoader__.load({
|
|
|
8133
9851
|
modelMenu.appendChild(el('div', 'dsh-sel-pickersplit'))
|
|
8134
9852
|
modelMenu.appendChild(el('div', 'dsh-sel-pickergroup', '推理等级(追问档)'))
|
|
8135
9853
|
var tiers = el('div', 'dsh-sel-tiers')
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
9854
|
+
/**
|
|
9855
|
+
* 档位按钮 = 与设置页**同一套四个格子**(关/低/高/最高)。
|
|
9856
|
+
*
|
|
9857
|
+
* 以前这里列的是"该模型自己声明的档位"(可能是 minimal/medium/xhigh 这种),于是
|
|
9858
|
+
* 两边词汇不同、还要把模型档位反向折回格子;现在两边同词同源:这里的选中值就是
|
|
9859
|
+
* 配置里的 `chatReasoningEffort`,改一边另一边跟着变,没有任何映射。
|
|
9860
|
+
* 格子落到模型哪个真实档位由 host 算(见设置页那行"当前模型:关 → …")。
|
|
9861
|
+
*/
|
|
9862
|
+
var tierItem = effectiveItem()
|
|
9863
|
+
var tierMap = (tierItem && tierItem.effortMap) || null
|
|
8144
9864
|
var badTiers = badTiersOf()
|
|
8145
9865
|
// host 在进程内学到的:这个模型"关"档会把思考写进正文
|
|
8146
9866
|
var leaky = effectiveChoice()
|
|
8147
9867
|
if (leaky && modelCatalog.leakyOff.indexOf(leaky.provider + '/' + leaky.model) >= 0) {
|
|
8148
9868
|
badTiers = { ...badTiers, off: 'leak' }
|
|
8149
9869
|
}
|
|
8150
|
-
var
|
|
8151
|
-
for (var t = 0; t <
|
|
8152
|
-
;(function (
|
|
8153
|
-
|
|
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])
|
|
8154
9877
|
button.type = 'button'
|
|
8155
|
-
button.setAttribute('data-on',
|
|
8156
|
-
|
|
9878
|
+
button.setAttribute('data-on', slot === activeSlot ? '1' : '0')
|
|
9879
|
+
button.setAttribute('data-slot', slot)
|
|
9880
|
+
if (bad) {
|
|
8157
9881
|
button.setAttribute('data-bad', '1')
|
|
8158
9882
|
button.title =
|
|
8159
|
-
|
|
8160
|
-
? '
|
|
8161
|
-
: '
|
|
9883
|
+
bad === 'leak'
|
|
9884
|
+
? '实测该模型在这一档会把思考写进正文:已自动改用「低」档'
|
|
9885
|
+
: '实测该模型不支持这一档:选它会自动回退到默认档'
|
|
8162
9886
|
}
|
|
9887
|
+
if (target !== slot) button.title = (button.title ? button.title + ' · ' : '') + '该模型发 ' + target
|
|
8163
9888
|
listen(button, 'click', function (event) {
|
|
8164
9889
|
event.preventDefault()
|
|
8165
9890
|
event.stopPropagation()
|
|
8166
|
-
|
|
8167
|
-
|
|
9891
|
+
// 唯一写入口:写的就是设置页那一行读的那个值
|
|
9892
|
+
setChatSlot(slot)
|
|
8168
9893
|
paintModelPill()
|
|
8169
9894
|
renderModelMenu()
|
|
8170
|
-
setStatus('追问档位:' +
|
|
9895
|
+
setStatus('追问档位:' + SLOT_LABEL[slot] + (target !== slot ? '(该模型发 ' + target + ')' : ''))
|
|
8171
9896
|
})
|
|
8172
9897
|
tiers.appendChild(button)
|
|
8173
|
-
})(
|
|
9898
|
+
})(SLOT_ORDER[t])
|
|
8174
9899
|
}
|
|
8175
9900
|
modelMenu.appendChild(tiers)
|
|
8176
9901
|
modelMenu.appendChild(
|
|
@@ -8247,8 +9972,8 @@ window.__ModuleLoader__.load({
|
|
|
8247
9972
|
// 模型/档位:从 localStorage 恢复;没存过就跟随"当前路由 + 配置档位"(ping 里就有)
|
|
8248
9973
|
// 从 host 的统一设置恢复模型,不读取旧浏览器模型覆盖。
|
|
8249
9974
|
loadSettings(false)
|
|
8250
|
-
|
|
8251
|
-
|
|
9975
|
+
// 「追问」档位不再有浏览器本地覆盖:设置页那一行、小窗那四个格子、发出去的请求
|
|
9976
|
+
// 读的都是同一份配置(chatReasoningEffort,加载完由 applyServerValues 同步进 state.effort)
|
|
8252
9977
|
paintModelPill()
|
|
8253
9978
|
fetch(PING + (currentSessionId() ? '?sessionId=' + encodeURIComponent(currentSessionId()) : ''), {
|
|
8254
9979
|
headers: { accept: 'application/json' },
|
|
@@ -8485,8 +10210,7 @@ window.__ModuleLoader__.load({
|
|
|
8485
10210
|
var question = askBox.value
|
|
8486
10211
|
// 空输入框照发**只在挂了引用时**成立(引用本身就是用户要送出去的内容)
|
|
8487
10212
|
if (!question.trim() && state.quotes.length === 0) return
|
|
8488
|
-
|
|
8489
|
-
refreshAskState()
|
|
10213
|
+
// 清空交给 ask():它收下才清(生成中被拒时保留你打的字)
|
|
8490
10214
|
ask(question)
|
|
8491
10215
|
})
|
|
8492
10216
|
|
|
@@ -8502,8 +10226,7 @@ window.__ModuleLoader__.load({
|
|
|
8502
10226
|
}
|
|
8503
10227
|
var question = askBox.value
|
|
8504
10228
|
if (!question.trim() && state.quotes.length === 0) return
|
|
8505
|
-
|
|
8506
|
-
refreshAskState()
|
|
10229
|
+
// 清空交给 ask():它收下才清
|
|
8507
10230
|
ask(question)
|
|
8508
10231
|
})
|
|
8509
10232
|
|
|
@@ -9762,7 +11485,7 @@ window.__ModuleLoader__.load({
|
|
|
9762
11485
|
if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
|
|
9763
11486
|
if (settingsButton.contains(event.target) || historyButton.contains(event.target)) return
|
|
9764
11487
|
event.preventDefault()
|
|
9765
|
-
var rect =
|
|
11488
|
+
var rect = panelLayoutRect()
|
|
9766
11489
|
var offsetX = event.clientX - rect.left
|
|
9767
11490
|
var offsetY = event.clientY - rect.top
|
|
9768
11491
|
panelInteracting = true
|
|
@@ -9781,6 +11504,7 @@ window.__ModuleLoader__.load({
|
|
|
9781
11504
|
window.addEventListener('mouseup', up, true)
|
|
9782
11505
|
})
|
|
9783
11506
|
|
|
11507
|
+
ensurePanelInsideViewport()
|
|
9784
11508
|
bindResizeGrip(gripCorner, 'se')
|
|
9785
11509
|
bindResizeGrip(gripRight, 'e')
|
|
9786
11510
|
bindResizeGrip(gripBottom, 's')
|
|
@@ -9798,6 +11522,58 @@ window.__ModuleLoader__.load({
|
|
|
9798
11522
|
|
|
9799
11523
|
// —— 调试钩子(自动化验证用) ——
|
|
9800
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
|
+
},
|
|
9801
11577
|
/** 重新读一次 host 上的小窗尺寸(测试用;正常路径只在挂载时读一次)。 */
|
|
9802
11578
|
loadPanelSize: function () {
|
|
9803
11579
|
panelSizeAsked = false
|
|
@@ -9806,7 +11582,7 @@ window.__ModuleLoader__.load({
|
|
|
9806
11582
|
},
|
|
9807
11583
|
/** 当前小窗几何(测试用:直接读渲染值,不经过变量)。 */
|
|
9808
11584
|
panelGeom: function () {
|
|
9809
|
-
var rect =
|
|
11585
|
+
var rect = panelLayoutRect()
|
|
9810
11586
|
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, resized: panel.getAttribute('data-resized') === '1' }
|
|
9811
11587
|
},
|
|
9812
11588
|
/** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
|
|
@@ -10036,8 +11812,7 @@ window.__ModuleLoader__.load({
|
|
|
10036
11812
|
stageEffort: state.stageEffort,
|
|
10037
11813
|
raw: state.raw,
|
|
10038
11814
|
model: state.model,
|
|
10039
|
-
sections: splitSections(
|
|
10040
|
-
toolResidue: state.toolResidue,
|
|
11815
|
+
sections: splitSections(state.raw),
|
|
10041
11816
|
tools: state.tools.length,
|
|
10042
11817
|
toolBusy: state.toolBusy,
|
|
10043
11818
|
toolDigest: state.toolDigest,
|
|
@@ -10056,10 +11831,6 @@ window.__ModuleLoader__.load({
|
|
|
10056
11831
|
pill: function () {
|
|
10057
11832
|
return pill
|
|
10058
11833
|
},
|
|
10059
|
-
/** 自检用:残渣清理(供单测直接打表)。 */
|
|
10060
|
-
stripResidue: function (text) {
|
|
10061
|
-
return sanitizeToolResidue(text)
|
|
10062
|
-
},
|
|
10063
11834
|
/** 自检用:模型/档位当前值 + 已加载的目录规模。 */
|
|
10064
11835
|
modelState: function () {
|
|
10065
11836
|
return {
|
|
@@ -10277,6 +12048,7 @@ window.__ModuleLoader__.load({
|
|
|
10277
12048
|
offMouseUp()
|
|
10278
12049
|
offKeyUp()
|
|
10279
12050
|
offSelectionChange()
|
|
12051
|
+
offVoiceEscape()
|
|
10280
12052
|
if (floatWatchId) clearInterval(floatWatchId)
|
|
10281
12053
|
offMouseDown()
|
|
10282
12054
|
offButtonDown()
|
|
@@ -10326,6 +12098,10 @@ window.__ModuleLoader__.load({
|
|
|
10326
12098
|
offBodyTouch()
|
|
10327
12099
|
offBodyScroll()
|
|
10328
12100
|
if (chatGrowWatch) chatGrowWatch.disconnect()
|
|
12101
|
+
if (panelGrowWatch) {
|
|
12102
|
+
panelGrowWatch.disconnect()
|
|
12103
|
+
panelGrowWatch = null
|
|
12104
|
+
}
|
|
10329
12105
|
if (pillPlacerId) clearTimeout(pillPlacerId)
|
|
10330
12106
|
if (pillObserver) {
|
|
10331
12107
|
try {
|