@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.
@@ -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
- if (!on) return
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', { off: '关', low: '低', high: '高', max: '最高' }[option] || option)
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
- if (spec.key === 'chatReasoningEffort') { state.effort = option; writeStore(EFFORT_KEY, option); paintModelPill() }
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', isDarkSurface(layer) ? 'dark' : 'light')
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 openPanelWith(text, context, label, anchor, keyContext) {
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
- var rect = panel.getBoundingClientRect()
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 = panel.getBoundingClientRect()
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
- expandButton.style.display = !hasDetail && translationReady && !asked ? '' : 'none'
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
- layer.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
5711
- panel.setAttribute('data-theme', isDarkSurface(panel) ? 'dark' : 'light')
5712
- askRow.style.display = wide || translationReady || state.quotes.length > 0 ? '' : 'none'
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
- if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
5812
- return node.isContentEditable === true
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
- /** 把光标交给输入框(preventScroll:别因为聚焦把页面滚一下)。 */
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 = panel.getBoundingClientRect()
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
- function stripTaggedBlocks(text, tag) {
6226
- var out = ''
6227
- var rest = text
6228
- var depth = 0
6229
- for (;;) {
6230
- tag.lastIndex = 0
6231
- var hit = tag.exec(rest)
6232
- if (!hit) {
6233
- if (depth === 0) out += rest
6234
- break
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
- if (depth === 0) out += rest.slice(0, hit.index)
6237
- depth = hit[0].charAt(1) === '/' ? Math.max(0, depth - 1) : depth + 1
6238
- rest = rest.slice(hit.index + hit[0].length)
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
- var cleaned = sanitizeToolResidue(state.raw)
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 = panel.getBoundingClientRect()
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, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
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, sanitizeToolResidue(turn.text) || '…', { settled: turn.streaming !== true })
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(sanitizeToolResidue(source.text))
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(sanitizeToolResidue(state.raw))
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
- ...(state.webAnswer ? { webAnswer: true } : {}),
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
- state.effort = null
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(sanitizeToolResidue(state.raw)).translation,
7602
- detail: state.parts.detail || splitSections(sanitizeToolResidue(state.raw)).detail,
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
- var EFFORT_KEY = 'dsh-selection-explain:effort'
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
- var effortIds = FALLBACK_TIERS
8137
- // 档位按钮列的是**当前在用那个模型**声明的档位(跟随态也照它列,否则列出来的档位
8138
- // 和实际发出去的模型对不上)
8139
- var inUseItem = effectiveItem()
8140
- if (inUseItem && (inUseItem.efforts || []).length > 0) {
8141
- effortIds = []
8142
- for (var e = 0; e < inUseItem.efforts.length; e += 1) effortIds.push(inUseItem.efforts[e].id)
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 active = state.effort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
8151
- for (var t = 0; t < effortIds.length; t += 1) {
8152
- ;(function (id) {
8153
- var button = el('button', 'dsh-sel-tierbtn', tierLabel(id))
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', id === active ? '1' : '0')
8156
- if (badTiers[id]) {
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
- badTiers[id] === 'leak'
8160
- ? '实测该模型在「' + tierLabel(id) + '」档会把思考写进正文:已自动改用「低」档'
8161
- : '实测该模型不支持「' + tierLabel(id) + '」档:选它会自动回退到默认档'
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
- state.effort = id
8167
- writeStore(EFFORT_KEY, id)
9891
+ // 唯一写入口:写的就是设置页那一行读的那个值
9892
+ setChatSlot(slot)
8168
9893
  paintModelPill()
8169
9894
  renderModelMenu()
8170
- setStatus('追问档位:' + tierLabel(id))
9895
+ setStatus('追问档位:' + SLOT_LABEL[slot] + (target !== slot ? '(该模型发 ' + target + ')' : ''))
8171
9896
  })
8172
9897
  tiers.appendChild(button)
8173
- })(effortIds[t])
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
- var storedEffort = readStore(EFFORT_KEY, '')
8251
- if (storedEffort) state.effort = storedEffort
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
- askBox.value = ''
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
- askBox.value = ''
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 = panel.getBoundingClientRect()
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 = panel.getBoundingClientRect()
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(sanitizeToolResidue(state.raw)),
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 {