@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 +36 -0
- package/lib/client.js +294 -3
- package/lib/index.js +147 -7
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/smoke-panel.mjs +287 -0
- package/scripts/test-client.mjs +18 -3
- package/src/client/index.js +294 -3
- package/src/index.ts +147 -8
package/src/client/index.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
|
-
|
|
334
|
-
|
|
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/src/index.ts
CHANGED
|
@@ -1321,6 +1321,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1321
1321
|
let modelCache: { at: number; items: ModelChoice[] } = { at: 0, items: [] }
|
|
1322
1322
|
/** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
|
|
1323
1323
|
const leakyOnOff = new Set<string>()
|
|
1324
|
+
/**
|
|
1325
|
+
* 哪些 provider/model **根本不接受 "关" 档**(上游直接 400 invalid_request_error)。
|
|
1326
|
+
*
|
|
1327
|
+
* 和 leakyOnOff 分开记:那个是"能发但会泄漏",这个是"发了就报错"。
|
|
1328
|
+
* 记下来之后不再发 off,直接改用 low —— 这是"最接近关"的可接受档位。
|
|
1329
|
+
* 不记的话每次划词都要先失败一轮再重试:用户看到一次报错闪动 + 多一个来回的时延。
|
|
1330
|
+
*/
|
|
1331
|
+
const noOffRoutes = new Set<string>()
|
|
1324
1332
|
const leakyFile = (): string => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'leaky-off.json')
|
|
1325
1333
|
let leakyLoaded = false
|
|
1326
1334
|
|
|
@@ -1330,17 +1338,20 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1330
1338
|
leakyLoaded = true
|
|
1331
1339
|
try {
|
|
1332
1340
|
const raw = await readFile(leakyFile(), 'utf8')
|
|
1333
|
-
const parsed = JSON.parse(raw) as { routes?: unknown }
|
|
1341
|
+
const parsed = JSON.parse(raw) as { routes?: unknown; noOff?: unknown }
|
|
1334
1342
|
if (Array.isArray(parsed?.routes)) {
|
|
1335
1343
|
for (const item of parsed.routes) if (typeof item === 'string' && item) leakyOnOff.add(item)
|
|
1336
1344
|
}
|
|
1345
|
+
if (Array.isArray(parsed?.noOff)) {
|
|
1346
|
+
for (const item of parsed.noOff) if (typeof item === 'string' && item) noOffRoutes.add(item)
|
|
1347
|
+
}
|
|
1337
1348
|
} catch {
|
|
1338
1349
|
/* 没有这个文件是常态 */
|
|
1339
1350
|
}
|
|
1340
1351
|
}
|
|
1341
1352
|
|
|
1342
1353
|
function saveLeaky(): void {
|
|
1343
|
-
const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff) }, null, 0)
|
|
1354
|
+
const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff), noOff: Array.from(noOffRoutes) }, null, 0)
|
|
1344
1355
|
void writeFile(leakyFile(), body, 'utf8').catch((error: unknown) => {
|
|
1345
1356
|
ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String((error as Error)?.message ?? error)}`)
|
|
1346
1357
|
})
|
|
@@ -1352,6 +1363,13 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1352
1363
|
leakyOnOff.add(route)
|
|
1353
1364
|
if (leakyLoaded) saveLeaky()
|
|
1354
1365
|
}
|
|
1366
|
+
|
|
1367
|
+
/** 记住一个"不接受关档"的模型(内存 + 落盘):下次直接改用 low,不再先撞一次。 */
|
|
1368
|
+
function markNoOff(route: string): void {
|
|
1369
|
+
if (noOffRoutes.has(route)) return
|
|
1370
|
+
noOffRoutes.add(route)
|
|
1371
|
+
if (leakyLoaded) saveLeaky()
|
|
1372
|
+
}
|
|
1355
1373
|
const MODEL_CACHE_MS = 60_000
|
|
1356
1374
|
|
|
1357
1375
|
async function listModelChoices(force = false): Promise<ModelChoice[]> {
|
|
@@ -1724,6 +1742,105 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1724
1742
|
pillPosTimer.unref?.()
|
|
1725
1743
|
}
|
|
1726
1744
|
|
|
1745
|
+
/**
|
|
1746
|
+
* 小窗的**尺寸**(拖角改过大小之后记下来)。
|
|
1747
|
+
*
|
|
1748
|
+
* 只记尺寸,**不记位置**:小窗每次开窗都由 client 按选区重新锚定
|
|
1749
|
+
* (落在选区下方,放不下就翻到上方,再夹进视口)—— 那套锚定逻辑是既有的设计,
|
|
1750
|
+
* 位置一旦被"记住"就等于架空了它,开窗不再跟着划词的地方走。
|
|
1751
|
+
* 尺寸则是用户明确调过的东西,值得跨开窗保留。
|
|
1752
|
+
*/
|
|
1753
|
+
const panelSizeFile = (): string =>
|
|
1754
|
+
join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'panel.json')
|
|
1755
|
+
|
|
1756
|
+
let panelSize: { w: number; h: number } | null = null
|
|
1757
|
+
let panelSizeLoaded = false
|
|
1758
|
+
let panelSizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* 尺寸校验:两个数都得是有限值,并夹到一个合理的区间。
|
|
1762
|
+
*
|
|
1763
|
+
* client 那边已经有 min/max 了,但文件是手工可编辑的(也可能被旧版本写坏),
|
|
1764
|
+
* 不能让一个 5px 宽的小窗把界面弄成没法用。
|
|
1765
|
+
*/
|
|
1766
|
+
function normalizePanelSize(input: unknown): { w: number; h: number } | null {
|
|
1767
|
+
const value = input as { w?: unknown; h?: unknown } | null | undefined
|
|
1768
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : NaN)
|
|
1769
|
+
const w = num(value?.w)
|
|
1770
|
+
const h = num(value?.h)
|
|
1771
|
+
if (!Number.isFinite(w) || !Number.isFinite(h)) return null
|
|
1772
|
+
const clamp = (n: number, lo: number, hi: number): number => Math.round(Math.min(hi, Math.max(lo, n)))
|
|
1773
|
+
return { w: clamp(w, 280, 4000), h: clamp(h, 200, 4000) }
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
async function loadPanelSize(): Promise<void> {
|
|
1777
|
+
if (panelSizeLoaded) return
|
|
1778
|
+
panelSizeLoaded = true
|
|
1779
|
+
try {
|
|
1780
|
+
const parsed = JSON.parse(await readFile(panelSizeFile(), 'utf8')) as { size?: unknown }
|
|
1781
|
+
panelSize = normalizePanelSize(parsed?.size)
|
|
1782
|
+
} catch {
|
|
1783
|
+
panelSize = null // 没存过 / 坏了都当作"没调过"
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function schedulePanelSizeWrite(): void {
|
|
1788
|
+
if (panelSizeTimer) clearTimeout(panelSizeTimer)
|
|
1789
|
+
panelSizeTimer = setTimeout(() => {
|
|
1790
|
+
panelSizeTimer = null
|
|
1791
|
+
void (async () => {
|
|
1792
|
+
try {
|
|
1793
|
+
await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true })
|
|
1794
|
+
await writeFile(panelSizeFile(), JSON.stringify({ version: 1, size: panelSize }, null, 0), 'utf8')
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
ctx.logger?.warn?.(`[${name}] 写入小窗尺寸失败:${String((error as Error)?.message ?? error)}`)
|
|
1797
|
+
}
|
|
1798
|
+
})()
|
|
1799
|
+
}, 300)
|
|
1800
|
+
panelSizeTimer.unref?.()
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* 小窗尺寸:GET 读回来(挂载时恢复)、POST 存({w,h})或清({clear:true},回到默认尺寸)。
|
|
1805
|
+
*/
|
|
1806
|
+
const handlePanelSize = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1807
|
+
void (async () => {
|
|
1808
|
+
await loadPanelSize()
|
|
1809
|
+
if (req.method === 'GET') {
|
|
1810
|
+
sendJson(res, 200, { ok: true, size: panelSize })
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
if (req.method !== 'POST') {
|
|
1814
|
+
sendJson(res, 405, { ok: false, error: '只支持 GET / POST' })
|
|
1815
|
+
return
|
|
1816
|
+
}
|
|
1817
|
+
const raw = await readBody(req, 4_000)
|
|
1818
|
+
let body: { size?: unknown; clear?: unknown } = {}
|
|
1819
|
+
try {
|
|
1820
|
+
body = raw ? (JSON.parse(raw) as { size?: unknown; clear?: unknown }) : {}
|
|
1821
|
+
} catch {
|
|
1822
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
if (body.clear === true) {
|
|
1826
|
+
panelSize = null
|
|
1827
|
+
schedulePanelSizeWrite()
|
|
1828
|
+
sendJson(res, 200, { ok: true, size: null })
|
|
1829
|
+
return
|
|
1830
|
+
}
|
|
1831
|
+
const next = normalizePanelSize(body.size ?? body)
|
|
1832
|
+
if (!next) {
|
|
1833
|
+
sendJson(res, 400, { ok: false, error: 'size 需要 { w, h } 两个有限数值' })
|
|
1834
|
+
return
|
|
1835
|
+
}
|
|
1836
|
+
panelSize = next
|
|
1837
|
+
schedulePanelSizeWrite()
|
|
1838
|
+
sendJson(res, 200, { ok: true, size: panelSize })
|
|
1839
|
+
})().catch((error: unknown) => {
|
|
1840
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1841
|
+
})
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1727
1844
|
/**
|
|
1728
1845
|
* 胶囊位置:GET 读回来(挂载时恢复)、POST 存({right,y})或清({clear:true},吸附回右下角时调)。
|
|
1729
1846
|
*/
|
|
@@ -3039,11 +3156,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3039
3156
|
text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
|
|
3040
3157
|
})
|
|
3041
3158
|
}
|
|
3042
|
-
|
|
3159
|
+
let requestEffort = isChat
|
|
3043
3160
|
? bodyEffort || config.chatReasoningEffort || effort
|
|
3044
3161
|
: stage === 'detail'
|
|
3045
3162
|
? effort
|
|
3046
3163
|
: config.translationReasoningEffort || effort
|
|
3164
|
+
// 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
|
|
3165
|
+
// 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
|
|
3166
|
+
if (requestEffort === 'off' && noOffRoutes.has(routeKey)) requestEffort = 'low'
|
|
3047
3167
|
|
|
3048
3168
|
sse(res, {
|
|
3049
3169
|
type: 'start',
|
|
@@ -3113,9 +3233,13 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3113
3233
|
error: '',
|
|
3114
3234
|
}
|
|
3115
3235
|
const toolRounds = config.maxToolRounds
|
|
3116
|
-
// 档位被上游拒绝时(400/401/500
|
|
3236
|
+
// 档位被上游拒绝时(400/401/500)重试一次:清单里声明的档位是**建议值**,
|
|
3117
3237
|
// 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
|
|
3118
|
-
|
|
3238
|
+
//
|
|
3239
|
+
// 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
|
|
3240
|
+
// 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
|
|
3241
|
+
// 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
|
|
3242
|
+
const canRetryEffort = requestEffort.length > 0
|
|
3119
3243
|
let effortRetried = false
|
|
3120
3244
|
let roundEffort = requestEffort
|
|
3121
3245
|
for (let round = 0; round <= toolRounds; round += 1) {
|
|
@@ -3240,13 +3364,24 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3240
3364
|
// (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
|
|
3241
3365
|
if (canRetryEffort && !effortRetried && roundText.length === 0) {
|
|
3242
3366
|
effortRetried = true
|
|
3243
|
-
|
|
3367
|
+
const rejected = roundEffort
|
|
3368
|
+
if (rejected === 'off') {
|
|
3369
|
+
// "关"被拒:记下这个 route,改用 low(最接近关的可接受档位),并让重试也用 low。
|
|
3370
|
+
// 只去掉档位(交给供应商默认)在有些模型上等于开了深度思考 —— 又慢又可能把思考写进正文。
|
|
3371
|
+
markNoOff(routeKey)
|
|
3372
|
+
requestEffort = 'low'
|
|
3373
|
+
roundEffort = 'low'
|
|
3374
|
+
} else {
|
|
3375
|
+
roundEffort = ''
|
|
3376
|
+
}
|
|
3244
3377
|
errored = false
|
|
3245
3378
|
sse(res, {
|
|
3246
3379
|
type: 'notice',
|
|
3247
3380
|
code: 'effort-rejected',
|
|
3248
|
-
tier:
|
|
3249
|
-
text:
|
|
3381
|
+
tier: rejected,
|
|
3382
|
+
text: rejected === 'off'
|
|
3383
|
+
? '该模型不支持「关」档,已自动改用「低」档(以后不再尝试关档)'
|
|
3384
|
+
: `「${rejected}」档位该模型不支持,已按默认档位重试`,
|
|
3250
3385
|
})
|
|
3251
3386
|
round -= 1
|
|
3252
3387
|
continue
|
|
@@ -3495,6 +3630,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3495
3630
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }),
|
|
3496
3631
|
`${name}: history route`,
|
|
3497
3632
|
)
|
|
3633
|
+
ctx.effect(
|
|
3634
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/panel`, handler: handlePanelSize }),
|
|
3635
|
+
`${name}: panel box route`,
|
|
3636
|
+
)
|
|
3498
3637
|
ctx.effect(
|
|
3499
3638
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }),
|
|
3500
3639
|
`${name}: pill position route`,
|