@yfwu2020/dsh-selection-explain 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -356,6 +356,42 @@ curl -s http://127.0.0.1:3080/selection-explain/api/ping
356
356
 
357
357
  ---
358
358
 
359
+ ## 档位兜底:「关」档不被支持时自动降级
360
+
361
+ 有些模型**不接受关闭推理**(上游直接 `400 invalid_request_error`)。首轮默认就是 `off`,
362
+ 撞上这种模型时整个划词解读会全挂。
363
+
364
+ 处理方式:
365
+
366
+ - 撞到一次就**记住这个 provider/model**(`${DSH_HOME:-~/.dsh}/selection-explain/leaky-off.json`
367
+ 的 `noOff` 字段),改用 **`low`**(最接近"关"的可接受档位)重试同一轮,并推一条 notice 说明。
368
+ - **下次不再尝试 `off`** —— 直接发 `low`,不再先失败一轮(省一个来回的时延,也不闪报错)。
369
+ - 重试只在"这一轮一个字都还没吐出来"时进行:已经出正文的失败不重试,避免把半截答案丢掉。
370
+ - 兜底覆盖**三种轮次**(首轮 / 详解 / 追问)。以前只对追问生效、而且要客户端显式传了档位才触发,
371
+ 所以首轮撞上这种模型时无人兜底。
372
+
373
+ ## 拖动与改大小
374
+
375
+ 小窗可以拖标题栏挪位置,拖右下角(或右/下边缘)改大小。
376
+
377
+ **位置和尺寸是两回事,只有尺寸会记住:**
378
+
379
+ - **位置不记**。每次开窗都按选区重新锚定:落在选区下方,下方放不下就翻到选区上方,
380
+ 再夹进视口。拖动只对这一次有效,关掉重开就回到划词的地方 —— 这是刻意的设计。
381
+ - **尺寸记**。拖角调过的大小存在 host 上(`${DSH_HOME:-~/.dsh}/selection-explain/panel.json`,
382
+ 接口 `GET/POST /selection-explain/api/panel`,载荷只有 `{w,h}`),
383
+ 刷新、换标签页、换浏览器 profile 都回到那个大小。
384
+ - **双击右下角角标**恢复默认尺寸(清掉存档)。
385
+
386
+ 其余约定:
387
+
388
+ - 尺寸下限 320×240,且始终夹在视口内(窗口缩小也不会超出)。上限是视口大小。
389
+ - 用户拉过大小后,设置页那条约 560px 的高度下限就**不再插手**(`data-resized` 标记)。
390
+ - 改动过程中不写盘,松手后防抖 300ms 再存 —— 不然每帧一个请求。
391
+ - 改大小用 **pointer 事件**(不是 mouse):触控板/触屏一样能拖,并且 `setPointerCapture`
392
+ 保证鼠标划出窗口也不会丢掉这一轮。
393
+ - 只有**真的拖动过**才会触发存档逻辑(点一下角标不算改了大小)。
394
+
359
395
  ## 配置
360
396
 
361
397
  ### 方式一:在小窗里改(推荐)
package/lib/client.js CHANGED
@@ -29,6 +29,8 @@ window.__ModuleLoader__.load({
29
29
  var PROMOTE = '/selection-explain/api/promote'
30
30
  /** 胶囊拖到别处后的位置(记在 host 上:拖到别处→存,吸附回右下角→清,挂载→读回来)。 */
31
31
  var PILL_POS = '/selection-explain/api/pill'
32
+ /** 小窗的**尺寸**(改过大小之后记在 host 上,刷新后回到那个大小)。位置不记。 */
33
+ var PANEL_SIZE = '/selection-explain/api/panel'
32
34
  var HISTORY = '/selection-explain/api/history'
33
35
  /** 模型清单(带每个模型支持的推理等级)。 */
34
36
  var MODELS = '/selection-explain/api/models'
@@ -330,11 +332,47 @@ window.__ModuleLoader__.load({
330
332
  '.dsh-sel-layer[data-theme=dark]{--sel-a1:#4ecdc4;--sel-a1-text:#6fe3d8;--sel-a2:#4ecdc4;--sel-a2-text:#6fe3d8;',
331
333
  '--sel-fill:#4ecdc4;--sel-fill-fg:#052b26;',
332
334
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 16%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 16%,transparent)}',
333
- '.dsh-sel-panel{position:fixed;display:none;flex-direction:column;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
334
- '.dsh-sel-panel[data-stage=translation]{width:min(440px,calc(100vw - 20px))}',
335
+ // ⚠️ 声明块必须在**同一段字符串里收尾**。
336
+ // 以前这里是 `...max-height:...;` 后面直接接了一条完整规则 `[data-stage=translation]{...}`,
337
+ // `.dsh-sel-panel{` 的 `}` 于是永远没出现 —— 那条阶段规则被当成块内的**非法声明**整条丢弃,
338
+ // "首轮收窄到 440" 从来没生效过(实测计算宽度一直是 540)。后面几段的 border-radius /
339
+ // overflow 之所以没事,是因为 CSS 出错恢复会跳过坏声明继续往下读。
340
+ // 单测只断言了 data-stage 这个**属性**,而桩 DOM 没有布局,所以一直没暴露。
341
+ // box-sizing:border-box 是**必须**的:拖角改大小时我们把 getBoundingClientRect()(含边框)
342
+ // 的宽高写回 style.width/height,而它们默认按 content-box 解释 —— 差一个边框宽(1px),
343
+ // 于是"存进去 → 读回来"每来一轮就胖 1px,拖几次就明显走形(实测 584 存进去、读回来 586)。
344
+ '.dsh-sel-panel{position:fixed;display:none;flex-direction:column;box-sizing:border-box;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
335
345
  'border-radius:16px;overflow:hidden;background:var(--dsw-alias-bg-layer-1,Canvas);color:var(--dsw-alias-label-primary,CanvasText);',
336
346
  'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));box-shadow:0 20px 52px rgba(0,0,0,.3);',
337
347
  'font:400 13.5px/1.7 ' + FONT + '}',
348
+ // 这里曾经有一条 `.dsh-sel-panel[data-stage=translation]{width:440px}`:
349
+ // "首轮只出翻译时收窄一点"。它因为上面那个漏掉的 `}` 一直是死代码,修好闭合之后才真正生效,
350
+ // 一生效就暴露了问题 —— 宽度会**在追问那一刻从 440 跳到 540**(见 syncPanelStage 的判定),
351
+ // 用户看到同一个窗口自己变宽,问"怎么有追问和没追问的宽度不一样"。
352
+ // 决定:**统一成 540,宽度不随阶段变**。少一档就没这档子事,也没有"什么时候会跳"的心智负担。
353
+ // (data-stage 本身保留 —— 它还决定追问输入框显不显示,见 renderPanel。)
354
+ // 设置抽屉开着时只补**高度**下限,**不动宽度**。
355
+ //
356
+ // 抽屉是 inset:0 贴在面板上的:面板多宽,抽屉就多宽 —— 这是要的(设置窗口与小窗等宽,
357
+ // 开抽屉时小窗不该"胖一圈")。但高度不能不管:面板高度是由"解读内容有多长"撑出来的,
358
+ // 刚开窗(只出了翻译)时面板才 ~115px 高,设置列表只剩 17px 可视高度(实测)。
359
+ // 所以只给 min-height;宽度交给面板自己的规则(首轮 440 / 其余 540),抽屉自动跟随。
360
+ // min-height 用 min() 与 max-height 同源,视口矮时两者相等,不会顶出屏幕。
361
+ // 只在**用户没自己改过尺寸**时才补这个下限:他拉过大小就按他的来(min-height 会压过 inline height)
362
+ '.dsh-sel-panel[data-settings="1"]:not([data-resized="1"]){min-height:min(560px,78vh)}',
363
+ // 改大小手柄:右下角一个角标 + 右边/下边各一条(细,不抢视线;hover/拖动时才明显)
364
+ // z-index 比设置抽屉(6)高 —— 但抽屉开着时整体隐藏(见下面的规则):
365
+ // 抽屉页脚右侧就是「关闭」按钮,角标压上去会误点。
366
+ '.dsh-sel-grip{position:absolute;z-index:7;background:transparent;touch-action:none}',
367
+ '.dsh-sel-grip-corner{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}',
368
+ '.dsh-sel-grip-r{right:0;top:12px;bottom:16px;width:6px;cursor:ew-resize}',
369
+ '.dsh-sel-grip-b{left:12px;right:16px;bottom:0;height:6px;cursor:ns-resize}',
370
+ // 手柄**不画任何常驻标识**:以前角上有一道小斜纹(两条 1.5px 边框拼的直角),
371
+ // 在浅色面板上看着像多出来的一笔,用户明确要求去掉。
372
+ // 保留 hover 时边缘的高亮(只在鼠标移上去时出现,不占视觉)——
373
+ // 加上 cursor: nwse-resize / ew-resize / ns-resize,可发现性还在。
374
+ // 抽屉开着时收起手柄(页脚右侧是「关闭」按钮,别让角标压上去)
375
+ '.dsh-sel-panel[data-settings="1"] .dsh-sel-grip{display:none}',
338
376
  '.dsh-sel-head{display:flex;align-items:center;gap:8px;padding:10px 10px 10px 14px;cursor:grab;user-select:none;',
339
377
  'border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
340
378
  '.dsh-sel-head:active{cursor:grabbing}',
@@ -2481,6 +2519,15 @@ window.__ModuleLoader__.load({
2481
2519
  settingsSheet.appendChild(settingsHead)
2482
2520
  settingsSheet.appendChild(settingsMain)
2483
2521
  settingsSheet.appendChild(settingsFoot)
2522
+ // ── 改大小手柄(右下角 + 右边缘 + 下边缘)──
2523
+ // 小窗的尺寸/位置都由这里驱动;拖完记在 host 上(见 savePanelBox)。
2524
+ var gripCorner = el('div', 'dsh-sel-grip dsh-sel-grip-corner')
2525
+ var gripRight = el('div', 'dsh-sel-grip dsh-sel-grip-r')
2526
+ var gripBottom = el('div', 'dsh-sel-grip dsh-sel-grip-b')
2527
+ gripCorner.title = '拖动改大小(双击恢复默认)'
2528
+ panel.appendChild(gripCorner)
2529
+ panel.appendChild(gripRight)
2530
+ panel.appendChild(gripBottom)
2484
2531
  panel.appendChild(settingsScrim)
2485
2532
  panel.appendChild(settingsSheet)
2486
2533
 
@@ -3292,6 +3339,8 @@ window.__ModuleLoader__.load({
3292
3339
  function openSettings() {
3293
3340
  if (settingsOpen) return
3294
3341
  settingsOpen = true
3342
+ // 抽屉贴着面板(inset:0),所以给面板一个合适的尺寸下限 —— 否则内容短时抽屉被压成一条
3343
+ panel.setAttribute('data-settings', '1')
3295
3344
  settingsSheet.setAttribute('data-open', '1')
3296
3345
  settingsScrim.setAttribute('data-open', '1')
3297
3346
  settingsButton.setAttribute('aria-pressed', 'true')
@@ -3306,6 +3355,7 @@ window.__ModuleLoader__.load({
3306
3355
  function closeSettings() {
3307
3356
  if (!settingsOpen) return
3308
3357
  settingsOpen = false
3358
+ panel.removeAttribute('data-settings')
3309
3359
  closeModelPicks()
3310
3360
  settingsSheet.setAttribute('data-open', '0')
3311
3361
  settingsScrim.setAttribute('data-open', '0')
@@ -5225,6 +5275,209 @@ window.__ModuleLoader__.load({
5225
5275
  return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }
5226
5276
  }
5227
5277
 
5278
+ /**
5279
+ * 小窗的尺寸(用户拖角改过之后才有值;null = 用 CSS 里的默认尺寸)。
5280
+ *
5281
+ * **只记尺寸,不记位置**:小窗每次开窗都必须按选区重新锚定(见 showPanel),
5282
+ * 这是既有的设计 —— 位置一旦被记住,开窗就不再跟着划词的地方走,等于把那套锚定架空了。
5283
+ * 尺寸是用户明确调过的东西,跨开窗保留才合理。
5284
+ */
5285
+ var panelSize = null
5286
+ var panelSizeAsked = false
5287
+ var panelSizeTimer = 0
5288
+ /** 正在拖手柄 / 拖标题栏时,别让 window resize 的夹取插一脚。 */
5289
+ var panelInteracting = false
5290
+ /**
5291
+ * 尺寸下限:比"设置页要放得下"低一点就行。真实约束在 CSS/JS 里:
5292
+ * 太窄了模型下拉和提示文案会挤成一团,太矮了正文没得看。
5293
+ */
5294
+ var PANEL_MIN_W = 320
5295
+ var PANEL_MIN_H = 240
5296
+
5297
+ /**
5298
+ * 开窗时应用"记住的尺寸"。
5299
+ *
5300
+ * ⚠️ 宽度是**固定值**,高度只能是**上限**(max-height)。
5301
+ *
5302
+ * 高度要是写成固定值,新开一个会话(内容只有一小段翻译)时面板会硬撑到上次那么大,
5303
+ * 下面留一大片空白 —— 那是回退。以前高度一直是内容撑的(`max-height` 只是个天花板),
5304
+ * 这个手感必须保住:内容短就短,内容长才长到上限、再滚动。
5305
+ *
5306
+ * 所以每次开窗都把 `height` **清掉**(回到自适应),只把用户调过的高度当作新的天花板。
5307
+ * 用户当场拖出来的尺寸在**本次**打开里照旧生效(拖动时直接写 inline height),
5308
+ * 只是下次开窗会重新按内容自适应 —— 既没有空白,拖动也看得见反馈。
5309
+ */
5310
+ function applyPanelSizeOnOpen() {
5311
+ // 先还原成"内容撑高 + CSS 默认天花板"
5312
+ panel.style.height = ''
5313
+ panel.style.maxHeight = ''
5314
+ if (!panelSize) {
5315
+ panel.style.width = ''
5316
+ panel.removeAttribute('data-resized')
5317
+ return
5318
+ }
5319
+ panel.style.width = Math.round(panelSize.w) + 'px'
5320
+ panel.style.maxHeight = Math.round(panelSize.h) + 'px'
5321
+ // 打过标记:设置页那条约 560 的 min-height 就别再插手了(用户已经自己定了高)
5322
+ panel.setAttribute('data-resized', '1')
5323
+ }
5324
+
5325
+ /** 当前尺寸(读实际渲染值,比拿变量算更稳 —— 拖拽过程中变量会漂)。 */
5326
+ function currentPanelSize() {
5327
+ var rect = panel.getBoundingClientRect()
5328
+ return { w: rect.width, h: rect.height }
5329
+ }
5330
+
5331
+ /** 夹进视口:改大小/窗口变化后都调,保证手柄还在屏幕里、小窗不会超出可视区。 */
5332
+ function clampPanelSize(size) {
5333
+ var maxW = Math.max(PANEL_MIN_W, window.innerWidth - 8)
5334
+ var maxH = Math.max(PANEL_MIN_H, window.innerHeight - 8)
5335
+ return {
5336
+ w: Math.min(maxW, Math.max(PANEL_MIN_W, size.w)),
5337
+ h: Math.min(maxH, Math.max(PANEL_MIN_H, size.h)),
5338
+ }
5339
+ }
5340
+
5341
+ /**
5342
+ * 把面板夹回可视区。
5343
+ *
5344
+ * **每次尺寸变化之后都要调**:位置是当初按旧尺寸算好的,尺寸一变就可能把面板顶出屏幕。
5345
+ * 用户报的就是这个 —— 小窗贴着屏幕边缘时双击角标恢复默认尺寸,一部分直接跑到屏幕外。
5346
+ * 优先保住左上角(那是用户看到的锚点),右边/下边放不下才往回收。
5347
+ */
5348
+ function clampPanelPosition() {
5349
+ var rect = panel.getBoundingClientRect()
5350
+ var maxLeft = Math.max(4, window.innerWidth - rect.width - 4)
5351
+ var maxTop = Math.max(4, window.innerHeight - rect.height - 4)
5352
+ panel.style.left = Math.round(Math.min(Math.max(rect.left, 4), maxLeft)) + 'px'
5353
+ panel.style.top = Math.round(Math.min(Math.max(rect.top, 4), maxTop)) + 'px'
5354
+ if (historyList.style.display === 'flex') placeHistoryList()
5355
+ }
5356
+
5357
+ /** 存到 host(防抖:拖动过程中每帧都存会把接口打爆)。 */
5358
+ function savePanelSize() {
5359
+ if (panelSizeTimer) clearTimeout(panelSizeTimer)
5360
+ panelSizeTimer = setTimeout(function () {
5361
+ panelSizeTimer = 0
5362
+ var size = clampPanelSize(currentPanelSize())
5363
+ panelSize = size
5364
+ fetch(PANEL_SIZE, {
5365
+ method: 'POST',
5366
+ headers: { 'content-type': 'application/json' },
5367
+ body: JSON.stringify({ size: size }),
5368
+ }).catch(function () { /* 存不上不影响使用,下次再改还会存 */ })
5369
+ }, 300)
5370
+ }
5371
+
5372
+ /** 挂载时问一次(异步,回来再摆;用户已经动过就以用户的为准)。 */
5373
+ function loadPanelSize() {
5374
+ if (panelSizeAsked) return
5375
+ panelSizeAsked = true
5376
+ fetch(PANEL_SIZE)
5377
+ .then(function (res) { return res.json() })
5378
+ .then(function (data) {
5379
+ var size = data && data.size
5380
+ if (!size || panelInteracting) return
5381
+ if (typeof size.w !== 'number' || typeof size.h !== 'number') return
5382
+ panelSize = clampPanelSize(size)
5383
+ if (panelOpen) {
5384
+ applyPanelSizeOnOpen()
5385
+ clampPanelPosition()
5386
+ }
5387
+ })
5388
+ .catch(function () {})
5389
+ }
5390
+
5391
+ /**
5392
+ * 恢复默认尺寸(双击右下角)。
5393
+ *
5394
+ * 和"高度自适应"是**同一个动作**:把整份存档清掉 ——
5395
+ * 宽度回到 CSS 默认(540 / 首轮阶段 440),高度清成 auto 由内容撑(也就是自适应)。
5396
+ *
5397
+ * ⚠️ 必须走 applyPanelSizeOnOpen() 来清,而不是手写两行 `style.width = ''`:
5398
+ * 高度上限是写在 `style.maxHeight` 上的,只清 width/height 会把它留下 ——
5399
+ * 用户先前把窗口**拉小**过的话,双击之后高度仍被旧上限卡着,看着就不像"恢复默认"。
5400
+ */
5401
+ function resetPanelSize() {
5402
+ panelSize = null
5403
+ // ⚠️ 先掐掉还没落盘的那次保存。
5404
+ // savePanelSize 是防抖的,而且**在定时器触发时**才去读当前尺寸 ——
5405
+ // 刚拖完角标马上双击还原时,这一次来不及取消的保存会在 300ms 后读到
5406
+ // "已经还原好的默认尺寸",又把它当成"用户选的尺寸"写回去:
5407
+ // 还原被无声撤销,宽度还被钉死在那一刻(连带把按阶段收窄那套也废掉)。
5408
+ if (panelSizeTimer) {
5409
+ clearTimeout(panelSizeTimer)
5410
+ panelSizeTimer = 0
5411
+ }
5412
+ applyPanelSizeOnOpen() // 宽度 / 高度 / 高度上限 一次全还原
5413
+ fetch(PANEL_SIZE, {
5414
+ method: 'POST',
5415
+ headers: { 'content-type': 'application/json' },
5416
+ body: JSON.stringify({ clear: true }),
5417
+ }).catch(function () {})
5418
+ // 宽度/高度都变了 → 位置得重夹,否则贴着屏幕边缘时会被顶出去
5419
+ clampPanelPosition()
5420
+ autoGrowAskBox()
5421
+ }
5422
+
5423
+ /**
5424
+ * 拖手柄改大小。
5425
+ *
5426
+ * edge 决定这次动哪几个方向:'se' 角标两个方向都能动,'e' 只动宽,'s' 只动高。
5427
+ * 用 pointer 事件(不是 mouse):触控板/触屏一样能拖,而且能 setPointerCapture,
5428
+ * 鼠标划出窗口也不会丢掉这一轮(mouse 版本会卡在中途)。
5429
+ */
5430
+ function bindResizeGrip(node, edge) {
5431
+ listen(node, 'pointerdown', function (event) {
5432
+ if (event.button !== undefined && event.button !== 0) return
5433
+ event.preventDefault()
5434
+ event.stopPropagation()
5435
+ var startSize = currentPanelSize()
5436
+ var startX = event.clientX
5437
+ var startY = event.clientY
5438
+ panelInteracting = true
5439
+ var layer = panel.parentNode
5440
+ if (layer) layer.setAttribute('data-resizing', '1')
5441
+ try { node.setPointerCapture(event.pointerId) } catch (error) { /* 老浏览器没有就算了 */ }
5442
+ var changed = 0
5443
+ var move = function (moveEvent) {
5444
+ var next = { w: startSize.w, h: startSize.h }
5445
+ if (edge.indexOf('e') >= 0) next.w = startSize.w + (moveEvent.clientX - startX)
5446
+ if (edge.indexOf('s') >= 0) next.h = startSize.h + (moveEvent.clientY - startY)
5447
+ var clamped = clampPanelSize(next)
5448
+ changed = Math.abs(clamped.w - startSize.w) + Math.abs(clamped.h - startSize.h)
5449
+ panel.style.width = Math.round(clamped.w) + 'px'
5450
+ panel.style.height = Math.round(clamped.h) + 'px'
5451
+ panelSize = clamped
5452
+ panel.setAttribute('data-resized', '1')
5453
+ if (historyList.style.display === 'flex') placeHistoryList()
5454
+ }
5455
+ var up = function () {
5456
+ window.removeEventListener('pointermove', move, true)
5457
+ window.removeEventListener('pointerup', up, true)
5458
+ panelInteracting = false
5459
+ if (layer) layer.removeAttribute('data-resizing')
5460
+ // 尺寸定下来后,正文/输入框要按新高度重新分配(composer 自动撑高那套)
5461
+ autoGrowAskBox()
5462
+ // 尺寸变了 → 位置重夹,别让面板被顶出屏幕
5463
+ if (changed >= 2) {
5464
+ clampPanelPosition()
5465
+ savePanelSize()
5466
+ }
5467
+ }
5468
+ window.addEventListener('pointermove', move, true)
5469
+ window.addEventListener('pointerup', up, true)
5470
+ })
5471
+ // 双击角标 = 恢复默认大小
5472
+ if (edge === 'se') {
5473
+ listen(node, 'dblclick', function (event) {
5474
+ event.preventDefault()
5475
+ event.stopPropagation()
5476
+ resetPanelSize()
5477
+ })
5478
+ }
5479
+ }
5480
+
5228
5481
  function showPanel(anchor) {
5229
5482
  panelOpen = true
5230
5483
  // 键盘用户开窗即可打字:这一轮的输入框一旦可见就聚焦一次(见 focusComposerOnce)
@@ -5236,6 +5489,11 @@ window.__ModuleLoader__.load({
5236
5489
  panel.style.display = 'flex'
5237
5490
  panel.style.left = '0px'
5238
5491
  panel.style.top = '0px'
5492
+ // 每次都按选区重新锚定 —— **不要**在这里插"用上次的位置"的分支:
5493
+ // 位置一旦被记住,开窗就不再跟着划词的地方走,这套锚定逻辑等于被架空。
5494
+ // 尺寸是另一回事:宽度沿用用户调过的,高度**每次开窗都回到自适应**(只保留天花板),
5495
+ // 否则新会话内容短、面板却硬撑成上次那么高,下面留一大片空白。
5496
+ applyPanelSizeOnOpen()
5239
5497
  // 面板刚显示出来才量得到 scrollHeight:草稿里原来的几行要立刻撑开
5240
5498
  autoGrowAskBox()
5241
5499
  var width = panel.offsetWidth
@@ -5399,7 +5657,8 @@ window.__ModuleLoader__.load({
5399
5657
  }
5400
5658
 
5401
5659
  // —— 按阶段重排 ——
5402
- // 只有翻译时:更窄的面板、不显示小节序号(轻量卡片形态)
5660
+ // 只有翻译时:不显示小节序号(轻量卡片形态)。宽度**不随阶段变**(恒 540)——
5661
+ // 以前这里还顺手收窄到 440,追问一下又跳回 540,用户看着像窗口自己抖了一下。
5403
5662
  var hasDetail = !!(state.parts.detail || stage === 'detail')
5404
5663
  // 追问输入框:**翻译一出来就能问,不必先展开详解**
5405
5664
  var translationReady = state.phase === 'done' && !!state.parts.translation
@@ -7979,6 +8238,7 @@ window.__ModuleLoader__.load({
7979
8238
 
7980
8239
  // 费用胶囊的位置/尺寸会变(展开、缩放、数字变长、插件迟到挂载)→ 自适应频率地贴上去
7981
8240
  loadPillPos() // 先问一句上次拖到哪儿(异步,回来再摆)
8241
+ loadPanelSize() // 小窗的尺寸也问一句(拖角改过之后才有的值;位置不记)
7982
8242
  placePill()
7983
8243
  ensureResizeWatch()
7984
8244
  schedulePillPlacer()
@@ -9371,10 +9631,12 @@ window.__ModuleLoader__.load({
9371
9631
 
9372
9632
  var offDrag = listen(head, 'mousedown', function (event) {
9373
9633
  if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
9634
+ if (settingsButton.contains(event.target) || historyButton.contains(event.target)) return
9374
9635
  event.preventDefault()
9375
9636
  var rect = panel.getBoundingClientRect()
9376
9637
  var offsetX = event.clientX - rect.left
9377
9638
  var offsetY = event.clientY - rect.top
9639
+ panelInteracting = true
9378
9640
  var move = function (moveEvent) {
9379
9641
  panel.style.left = clamp(moveEvent.clientX - offsetX, 4, window.innerWidth - rect.width - 4) + 'px'
9380
9642
  panel.style.top = clamp(moveEvent.clientY - offsetY, 4, window.innerHeight - 40) + 'px'
@@ -9383,13 +9645,41 @@ window.__ModuleLoader__.load({
9383
9645
  var up = function () {
9384
9646
  window.removeEventListener('mousemove', move, true)
9385
9647
  window.removeEventListener('mouseup', up, true)
9648
+ panelInteracting = false
9649
+ // **不存位置**:拖动只对这一次有效;下次开窗仍按选区重新锚定。
9386
9650
  }
9387
9651
  window.addEventListener('mousemove', move, true)
9388
9652
  window.addEventListener('mouseup', up, true)
9389
9653
  })
9390
9654
 
9655
+ bindResizeGrip(gripCorner, 'se')
9656
+ bindResizeGrip(gripRight, 'e')
9657
+ bindResizeGrip(gripBottom, 's')
9658
+
9659
+ // 窗口变小:把**尺寸**夹回可视区(位置不归这里管 —— 它由 showPanel 的锚定决定)
9660
+ var offPanelResize = listen(window, 'resize', function () {
9661
+ if (!panelSize || panelInteracting) return
9662
+ var next = clampPanelSize({ w: panelSize.w, h: panelSize.h })
9663
+ if (next.w === panelSize.w && next.h === panelSize.h) return
9664
+ panelSize = next
9665
+ applyPanelSizeOnOpen()
9666
+ clampPanelPosition()
9667
+ savePanelSize()
9668
+ })
9669
+
9391
9670
  // —— 调试钩子(自动化验证用) ——
9392
9671
  window.__dshSelectionExplain = {
9672
+ /** 重新读一次 host 上的小窗尺寸(测试用;正常路径只在挂载时读一次)。 */
9673
+ loadPanelSize: function () {
9674
+ panelSizeAsked = false
9675
+ loadPanelSize()
9676
+ return true
9677
+ },
9678
+ /** 当前小窗几何(测试用:直接读渲染值,不经过变量)。 */
9679
+ panelGeom: function () {
9680
+ var rect = panel.getBoundingClientRect()
9681
+ return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, resized: panel.getAttribute('data-resized') === '1' }
9682
+ },
9393
9683
  /** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
9394
9684
  loadPillPos: function () {
9395
9685
  pillPosAsked = false
@@ -9887,6 +10177,7 @@ window.__ModuleLoader__.load({
9887
10177
  cancelVoice()
9888
10178
  offClose()
9889
10179
  offDrag()
10180
+ offPanelResize()
9890
10181
  offPillDown()
9891
10182
  offPillClick()
9892
10183
  offPillEnter()
package/lib/index.js CHANGED
@@ -1047,6 +1047,14 @@ export function apply(ctx, rawConfig) {
1047
1047
  let modelCache = { at: 0, items: [] };
1048
1048
  /** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
1049
1049
  const leakyOnOff = new Set();
1050
+ /**
1051
+ * 哪些 provider/model **根本不接受 "关" 档**(上游直接 400 invalid_request_error)。
1052
+ *
1053
+ * 和 leakyOnOff 分开记:那个是"能发但会泄漏",这个是"发了就报错"。
1054
+ * 记下来之后不再发 off,直接改用 low —— 这是"最接近关"的可接受档位。
1055
+ * 不记的话每次划词都要先失败一轮再重试:用户看到一次报错闪动 + 多一个来回的时延。
1056
+ */
1057
+ const noOffRoutes = new Set();
1050
1058
  const leakyFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'leaky-off.json');
1051
1059
  let leakyLoaded = false;
1052
1060
  /** 载入"关档会泄漏思考"的模型清单(跨重启保留;文件坏了就当空)。 */
@@ -1062,13 +1070,18 @@ export function apply(ctx, rawConfig) {
1062
1070
  if (typeof item === 'string' && item)
1063
1071
  leakyOnOff.add(item);
1064
1072
  }
1073
+ if (Array.isArray(parsed?.noOff)) {
1074
+ for (const item of parsed.noOff)
1075
+ if (typeof item === 'string' && item)
1076
+ noOffRoutes.add(item);
1077
+ }
1065
1078
  }
1066
1079
  catch {
1067
1080
  /* 没有这个文件是常态 */
1068
1081
  }
1069
1082
  }
1070
1083
  function saveLeaky() {
1071
- const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff) }, null, 0);
1084
+ const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff), noOff: Array.from(noOffRoutes) }, null, 0);
1072
1085
  void writeFile(leakyFile(), body, 'utf8').catch((error) => {
1073
1086
  ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String(error?.message ?? error)}`);
1074
1087
  });
@@ -1081,6 +1094,14 @@ export function apply(ctx, rawConfig) {
1081
1094
  if (leakyLoaded)
1082
1095
  saveLeaky();
1083
1096
  }
1097
+ /** 记住一个"不接受关档"的模型(内存 + 落盘):下次直接改用 low,不再先撞一次。 */
1098
+ function markNoOff(route) {
1099
+ if (noOffRoutes.has(route))
1100
+ return;
1101
+ noOffRoutes.add(route);
1102
+ if (leakyLoaded)
1103
+ saveLeaky();
1104
+ }
1084
1105
  const MODEL_CACHE_MS = 60_000;
1085
1106
  async function listModelChoices(force = false) {
1086
1107
  if (!force && modelCache.items.length > 0 && Date.now() - modelCache.at < MODEL_CACHE_MS)
@@ -1402,6 +1423,104 @@ export function apply(ctx, rawConfig) {
1402
1423
  }, 300);
1403
1424
  pillPosTimer.unref?.();
1404
1425
  }
1426
+ /**
1427
+ * 小窗的**尺寸**(拖角改过大小之后记下来)。
1428
+ *
1429
+ * 只记尺寸,**不记位置**:小窗每次开窗都由 client 按选区重新锚定
1430
+ * (落在选区下方,放不下就翻到上方,再夹进视口)—— 那套锚定逻辑是既有的设计,
1431
+ * 位置一旦被"记住"就等于架空了它,开窗不再跟着划词的地方走。
1432
+ * 尺寸则是用户明确调过的东西,值得跨开窗保留。
1433
+ */
1434
+ const panelSizeFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'panel.json');
1435
+ let panelSize = null;
1436
+ let panelSizeLoaded = false;
1437
+ let panelSizeTimer = null;
1438
+ /**
1439
+ * 尺寸校验:两个数都得是有限值,并夹到一个合理的区间。
1440
+ *
1441
+ * client 那边已经有 min/max 了,但文件是手工可编辑的(也可能被旧版本写坏),
1442
+ * 不能让一个 5px 宽的小窗把界面弄成没法用。
1443
+ */
1444
+ function normalizePanelSize(input) {
1445
+ const value = input;
1446
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : NaN);
1447
+ const w = num(value?.w);
1448
+ const h = num(value?.h);
1449
+ if (!Number.isFinite(w) || !Number.isFinite(h))
1450
+ return null;
1451
+ const clamp = (n, lo, hi) => Math.round(Math.min(hi, Math.max(lo, n)));
1452
+ return { w: clamp(w, 280, 4000), h: clamp(h, 200, 4000) };
1453
+ }
1454
+ async function loadPanelSize() {
1455
+ if (panelSizeLoaded)
1456
+ return;
1457
+ panelSizeLoaded = true;
1458
+ try {
1459
+ const parsed = JSON.parse(await readFile(panelSizeFile(), 'utf8'));
1460
+ panelSize = normalizePanelSize(parsed?.size);
1461
+ }
1462
+ catch {
1463
+ panelSize = null; // 没存过 / 坏了都当作"没调过"
1464
+ }
1465
+ }
1466
+ function schedulePanelSizeWrite() {
1467
+ if (panelSizeTimer)
1468
+ clearTimeout(panelSizeTimer);
1469
+ panelSizeTimer = setTimeout(() => {
1470
+ panelSizeTimer = null;
1471
+ void (async () => {
1472
+ try {
1473
+ await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
1474
+ await writeFile(panelSizeFile(), JSON.stringify({ version: 1, size: panelSize }, null, 0), 'utf8');
1475
+ }
1476
+ catch (error) {
1477
+ ctx.logger?.warn?.(`[${name}] 写入小窗尺寸失败:${String(error?.message ?? error)}`);
1478
+ }
1479
+ })();
1480
+ }, 300);
1481
+ panelSizeTimer.unref?.();
1482
+ }
1483
+ /**
1484
+ * 小窗尺寸:GET 读回来(挂载时恢复)、POST 存({w,h})或清({clear:true},回到默认尺寸)。
1485
+ */
1486
+ const handlePanelSize = (req, res) => {
1487
+ void (async () => {
1488
+ await loadPanelSize();
1489
+ if (req.method === 'GET') {
1490
+ sendJson(res, 200, { ok: true, size: panelSize });
1491
+ return;
1492
+ }
1493
+ if (req.method !== 'POST') {
1494
+ sendJson(res, 405, { ok: false, error: '只支持 GET / POST' });
1495
+ return;
1496
+ }
1497
+ const raw = await readBody(req, 4_000);
1498
+ let body = {};
1499
+ try {
1500
+ body = raw ? JSON.parse(raw) : {};
1501
+ }
1502
+ catch {
1503
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
1504
+ return;
1505
+ }
1506
+ if (body.clear === true) {
1507
+ panelSize = null;
1508
+ schedulePanelSizeWrite();
1509
+ sendJson(res, 200, { ok: true, size: null });
1510
+ return;
1511
+ }
1512
+ const next = normalizePanelSize(body.size ?? body);
1513
+ if (!next) {
1514
+ sendJson(res, 400, { ok: false, error: 'size 需要 { w, h } 两个有限数值' });
1515
+ return;
1516
+ }
1517
+ panelSize = next;
1518
+ schedulePanelSizeWrite();
1519
+ sendJson(res, 200, { ok: true, size: panelSize });
1520
+ })().catch((error) => {
1521
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
1522
+ });
1523
+ };
1405
1524
  /**
1406
1525
  * 胶囊位置:GET 读回来(挂载时恢复)、POST 存({right,y})或清({clear:true},吸附回右下角时调)。
1407
1526
  */
@@ -2666,11 +2785,15 @@ export function apply(ctx, rawConfig) {
2666
2785
  text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
2667
2786
  });
2668
2787
  }
2669
- const requestEffort = isChat
2788
+ let requestEffort = isChat
2670
2789
  ? bodyEffort || config.chatReasoningEffort || effort
2671
2790
  : stage === 'detail'
2672
2791
  ? effort
2673
2792
  : config.translationReasoningEffort || effort;
2793
+ // 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
2794
+ // 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
2795
+ if (requestEffort === 'off' && noOffRoutes.has(routeKey))
2796
+ requestEffort = 'low';
2674
2797
  sse(res, {
2675
2798
  type: 'start',
2676
2799
  provider: route.provider,
@@ -2739,9 +2862,13 @@ export function apply(ctx, rawConfig) {
2739
2862
  error: '',
2740
2863
  };
2741
2864
  const toolRounds = config.maxToolRounds;
2742
- // 档位被上游拒绝时(400/401/500)去掉档位重试一次:清单里声明的档位是**建议值**,
2865
+ // 档位被上游拒绝时(400/401/500)重试一次:清单里声明的档位是**建议值**,
2743
2866
  // 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
2744
- const canRetryEffort = isChat && bodyEffort.length > 0;
2867
+ //
2868
+ // 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
2869
+ // 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
2870
+ // 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
2871
+ const canRetryEffort = requestEffort.length > 0;
2745
2872
  let effortRetried = false;
2746
2873
  let roundEffort = requestEffort;
2747
2874
  for (let round = 0; round <= toolRounds; round += 1) {
@@ -2867,13 +2994,25 @@ export function apply(ctx, rawConfig) {
2867
2994
  // (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
2868
2995
  if (canRetryEffort && !effortRetried && roundText.length === 0) {
2869
2996
  effortRetried = true;
2870
- roundEffort = '';
2997
+ const rejected = roundEffort;
2998
+ if (rejected === 'off') {
2999
+ // "关"被拒:记下这个 route,改用 low(最接近关的可接受档位),并让重试也用 low。
3000
+ // 只去掉档位(交给供应商默认)在有些模型上等于开了深度思考 —— 又慢又可能把思考写进正文。
3001
+ markNoOff(routeKey);
3002
+ requestEffort = 'low';
3003
+ roundEffort = 'low';
3004
+ }
3005
+ else {
3006
+ roundEffort = '';
3007
+ }
2871
3008
  errored = false;
2872
3009
  sse(res, {
2873
3010
  type: 'notice',
2874
3011
  code: 'effort-rejected',
2875
- tier: bodyEffort,
2876
- text: `「${bodyEffort}」档位该模型不支持,已按默认档位重试`,
3012
+ tier: rejected,
3013
+ text: rejected === 'off'
3014
+ ? '该模型不支持「关」档,已自动改用「低」档(以后不再尝试关档)'
3015
+ : `「${rejected}」档位该模型不支持,已按默认档位重试`,
2877
3016
  });
2878
3017
  round -= 1;
2879
3018
  continue;
@@ -3116,6 +3255,7 @@ export function apply(ctx, rawConfig) {
3116
3255
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/route`, handler: handleRoute }), `${name}: route route`);
3117
3256
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/ping`, handler: handlePing }), `${name}: ping route`);
3118
3257
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }), `${name}: history route`);
3258
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/panel`, handler: handlePanelSize }), `${name}: panel box route`);
3119
3259
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }), `${name}: pill position route`);
3120
3260
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }), `${name}: models route`);
3121
3261
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }), `${name}: promote route`);