@yfwu2020/dsh-selection-explain 0.7.1 → 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 +112 -5
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1734 -77
- package/lib/index.js +513 -32
- package/lib/index.js.map +1 -1
- package/lib/settings.js +228 -0
- package/lib/settings.js.map +1 -0
- package/lib/types/index.d.ts +37 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +333 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +661 -10
- package/scripts/test-model-follow.mjs +193 -0
- package/scripts/test-route-cache.mjs +138 -0
- package/scripts/test-settings-behavior.mjs +188 -0
- package/scripts/test-settings-host.mjs +48 -0
- package/scripts/test-settings.mjs +329 -0
- package/src/client/index.js +1734 -77
- package/src/index.ts +549 -28
- package/src/settings.ts +310 -0
package/scripts/test-client.mjs
CHANGED
|
@@ -400,6 +400,21 @@ const routeFetch = (input, init) => {
|
|
|
400
400
|
const body = globalThis.__modelCatalogFixture || { ok: true, current: null, stages: null, models: [] }
|
|
401
401
|
return Promise.resolve(new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }))
|
|
402
402
|
}
|
|
403
|
+
// 「这一轮用哪个模型」的轻量查询(设置页轮询它做实时刷新):同样走 fixture,
|
|
404
|
+
// 否则用例会打到真实 host,拿到的模型随本机会话变,断言就不确定了。
|
|
405
|
+
if (url.indexOf('/selection-explain/api/route') >= 0) {
|
|
406
|
+
const chat = url.indexOf('chat=1') >= 0
|
|
407
|
+
const fx = globalThis.__modelCatalogFixture || { current: null }
|
|
408
|
+
const chatFx = globalThis.__chatRouteFixture
|
|
409
|
+
const cur = chat && chatFx ? chatFx : fx.current
|
|
410
|
+
const body = {
|
|
411
|
+
ok: true,
|
|
412
|
+
provider: (cur && cur.provider) || '',
|
|
413
|
+
model: (cur && cur.model) || '',
|
|
414
|
+
chatFollows: !(chat && chatFx),
|
|
415
|
+
}
|
|
416
|
+
return Promise.resolve(new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }))
|
|
417
|
+
}
|
|
403
418
|
// 小窗历史(A′):内存版实现,避免打到真实 host 拿到脏历史
|
|
404
419
|
if (url.indexOf('/selection-explain/api/history') >= 0) {
|
|
405
420
|
if (init && init.method === 'DELETE') {
|
|
@@ -1005,9 +1020,15 @@ const ctx = {
|
|
|
1005
1020
|
},
|
|
1006
1021
|
on() {},
|
|
1007
1022
|
get(name) {
|
|
1023
|
+
if (name === 'uiSession') {
|
|
1024
|
+
return { adapter: { current: { getSnapshot: () => ({
|
|
1025
|
+
key: globalThis.__sessionStubId || 'session-stub-1',
|
|
1026
|
+
props: { sessionId: globalThis.__sessionStubId || 'session-stub-1' },
|
|
1027
|
+
}) } } }
|
|
1028
|
+
}
|
|
1008
1029
|
if (name === 'sessions') {
|
|
1009
1030
|
return {
|
|
1010
|
-
list: { getSnapshot: () => ({
|
|
1031
|
+
list: { getSnapshot: () => ({ ids: ['session-stub-1'], byId: {}, phase: 'ready', projectionsBySession: {} }) },
|
|
1011
1032
|
open: (id) => {
|
|
1012
1033
|
openedSessions.push(String(id))
|
|
1013
1034
|
},
|
|
@@ -1083,6 +1104,21 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
|
|
|
1083
1104
|
|
|
1084
1105
|
|
|
1085
1106
|
// ───────────────────────── 点击 → 弹窗 + 真实 host 流 ─────────────────────────
|
|
1107
|
+
// ⚠️ 开场先清一次设置覆盖。
|
|
1108
|
+
// 这套用例后面会把 provider/model 写成夹具里的假 provider(p1),而它落在**真实 host** 上;
|
|
1109
|
+
// 万一上一次跑崩在中途(没走到收尾的 reset),残留的 p1 会让接下来的真实解读全部 503 ——
|
|
1110
|
+
// 表现为"流式渲染失败"一大片,看起来像插件坏了,其实是脏状态。
|
|
1111
|
+
// 所以清理要放在**最前面**(收尾那次仍然保留,正常路径下不留痕迹)。
|
|
1112
|
+
try {
|
|
1113
|
+
await fetch(ORIGIN + '/selection-explain/api/settings', {
|
|
1114
|
+
method: 'POST',
|
|
1115
|
+
headers: { 'content-type': 'application/json' },
|
|
1116
|
+
body: JSON.stringify({ reset: true }),
|
|
1117
|
+
})
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
/* host 不可达时后面本来就会跳过在线断言 */
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1086
1122
|
button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1087
1123
|
const panel = mount.children[0].children.find((c) => c.className === 'dsh-sel-panel')
|
|
1088
1124
|
// 面板内的固定引用点(后续断言共用)
|
|
@@ -1187,10 +1223,10 @@ if (HOST_UP) {
|
|
|
1187
1223
|
)
|
|
1188
1224
|
}
|
|
1189
1225
|
assert(
|
|
1190
|
-
'追问档位默认 high
|
|
1226
|
+
'追问档位默认 high,首轮默认 off',
|
|
1191
1227
|
/chatReasoningEffort: z\.string\(\)\.default\('high'\)/.test(hostSrc) &&
|
|
1192
1228
|
/chatReasoningEffort: rawConfig\?\.chatReasoningEffort \?\? 'high'/.test(hostSrc) &&
|
|
1193
|
-
/translationReasoningEffort: z\.string\(\)\.default\('
|
|
1229
|
+
/translationReasoningEffort: z\.string\(\)\.default\('off'\)/.test(hostSrc),
|
|
1194
1230
|
'src/index.ts',
|
|
1195
1231
|
)
|
|
1196
1232
|
}
|
|
@@ -1219,7 +1255,7 @@ assert(
|
|
|
1219
1255
|
|
|
1220
1256
|
// 只有翻译时的轻量形态:窄面板 / 无序号 / CTA 在内容流里(但**可以越过详解直接追问**)
|
|
1221
1257
|
const translateCard = sections.find((node) => node.getAttribute('data-sec') === 'translation')
|
|
1222
|
-
assert('首轮面板标为 translation
|
|
1258
|
+
assert('首轮面板标为 translation 阶段(决定追问框显示,不决定宽度)', panel.getAttribute('data-stage') === 'translation', String(panel.getAttribute('data-stage')))
|
|
1223
1259
|
assert(
|
|
1224
1260
|
'两节标题都不带序号(①② 已去掉)',
|
|
1225
1261
|
!Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
|
|
@@ -1243,7 +1279,7 @@ assert(
|
|
|
1243
1279
|
assert('前情只带翻译:不留空的「详解:」', seed.indexOf('前情') >= 0 && seed.indexOf('详解:') < 0, seed.slice(0, 70))
|
|
1244
1280
|
for (let i = 0; i < 80 && textOf(chatLog).indexOf('这是对追问的回答') < 0; i += 1) await sleep(20)
|
|
1245
1281
|
assert('回答渲染进小窗消息区', textOf(chatLog).indexOf('这是对追问的回答') >= 0, textOf(chatLog).slice(0, 60))
|
|
1246
|
-
assert('
|
|
1282
|
+
assert('一旦有对话,面板切到 detail 阶段', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
|
|
1247
1283
|
assert('发过追问后「展开详解」自动收起', expandBtn.style.display === 'none', String(expandBtn.style.display))
|
|
1248
1284
|
|
|
1249
1285
|
// 换一段新的选中文字:状态重置,CTA 该回来(后面的"点展开"流程从这里接着走)。
|
|
@@ -1274,7 +1310,7 @@ for (let i = 0; i < 100 && !(hook.parts().detail || '').trim(); i += 1) await sl
|
|
|
1274
1310
|
for (let i = 0; i < 100 && hook.state().phase !== 'done'; i += 1) await sleep(50)
|
|
1275
1311
|
assert('第二阶段产出详解', (hook.parts().detail || '').trim().length > 0, (hook.parts().detail || '').trim().slice(0, 60).replace(/\n/g, ' '))
|
|
1276
1312
|
assert('展开后按钮隐藏、详解卡片出现', expandBtn.style.display === 'none' && detailCard.style.display !== 'none', `btn=${expandBtn.style.display} card=${detailCard.style.display}`)
|
|
1277
|
-
assert('展开后面板切到 detail
|
|
1313
|
+
assert('展开后面板切到 detail 阶段', panel.getAttribute('data-stage') === 'detail', String(panel.getAttribute('data-stage')))
|
|
1278
1314
|
assert(
|
|
1279
1315
|
'展开后标题仍不带序号',
|
|
1280
1316
|
!Array.from(walk(panel)).some((n) => n.tagName === 'B' && /^[①②12]$/.test(textOf(n))),
|
|
@@ -1391,11 +1427,13 @@ assert(
|
|
|
1391
1427
|
)
|
|
1392
1428
|
|
|
1393
1429
|
assert(
|
|
1394
|
-
'右上角是「最近 + 升格 +
|
|
1430
|
+
'右上角是「最近 + 升格 + 设置 + 关闭」四个,没有复制按钮',
|
|
1395
1431
|
!Array.from(walk(panelHead)).some((n) => textOf(n).indexOf('复制') >= 0) &&
|
|
1396
1432
|
Array.from(walk(panelHead)).filter((n) => n.className === 'dsh-sel-icon').length === 1 &&
|
|
1397
|
-
|
|
1398
|
-
Array.from(walk(panelHead)).
|
|
1433
|
+
// 文字动作键:最近 / 升格 / 设置 —— 三个同款(设置键也带文字,不再是一枚裸图标)
|
|
1434
|
+
Array.from(walk(panelHead)).filter((n) => n.className.indexOf('dsh-sel-action') >= 0).length === 3 &&
|
|
1435
|
+
Array.from(walk(panelHead)).some((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('最近') >= 0) &&
|
|
1436
|
+
Array.from(walk(panelHead)).some((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('设置') >= 0),
|
|
1399
1437
|
Array.from(walk(panelHead)).map((n) => n.className + ':' + textOf(n).slice(0, 6)).join(' | '),
|
|
1400
1438
|
)
|
|
1401
1439
|
const promoteBtn = Array.from(walk(panelHead)).find((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('升格') >= 0)
|
|
@@ -1405,6 +1443,617 @@ assert(
|
|
|
1405
1443
|
promoteBtn ? promoteBtn.className + '|' + textOf(promoteBtn) : '未找到',
|
|
1406
1444
|
)
|
|
1407
1445
|
|
|
1446
|
+
// ───────────────────────── 设置(小窗设置抽屉) ─────────────────────────
|
|
1447
|
+
// 设计约束(用户明确要求):设置键与「最近 / 升格」**同款** —— 带边框的文字键、图标 + 文字,
|
|
1448
|
+
// 而不是一枚孤零零的裸图标。图标本身走统一 SVG 规范(16 画布 / 14px / stroke 1.5)。
|
|
1449
|
+
const settingsBtn = Array.from(walk(panelHead)).find(
|
|
1450
|
+
(n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('设置') >= 0,
|
|
1451
|
+
)
|
|
1452
|
+
assert(
|
|
1453
|
+
'头部有「设置」文字键(不是裸图标)',
|
|
1454
|
+
!!settingsBtn && settingsBtn.className.indexOf('dsh-sel-action') >= 0,
|
|
1455
|
+
settingsBtn ? settingsBtn.className + '|' + textOf(settingsBtn) : '未找到',
|
|
1456
|
+
)
|
|
1457
|
+
assert(
|
|
1458
|
+
'「设置」键的图标与「最近 / 升格」同款(都是 14px 的 SVG,不是 Unicode 字形)',
|
|
1459
|
+
!!settingsBtn && !!Array.from(walk(settingsBtn)).find((n) => n.tagName === 'SVG' && n.attrs.width === '14'),
|
|
1460
|
+
settingsBtn ? Array.from(walk(settingsBtn)).map((n) => n.tagName + '/' + (n.attrs.width || '')).join(',') : '未找到',
|
|
1461
|
+
)
|
|
1462
|
+
// 头部每个图标都得走同一套画布:viewBox 16×16、stroke-width 1.5、linecap round。
|
|
1463
|
+
// 混用 Unicode 字形(🕘 ↗ ✕)时这组属性根本不存在 —— 这条断言就是防它回潮。
|
|
1464
|
+
{
|
|
1465
|
+
const headIcons = []
|
|
1466
|
+
for (const btn of walk(panelHead)) {
|
|
1467
|
+
if (btn.tagName !== 'SVG') continue
|
|
1468
|
+
headIcons.push(btn)
|
|
1469
|
+
}
|
|
1470
|
+
const uniform = headIcons.length >= 4 && headIcons.every((svg) =>
|
|
1471
|
+
svg.attrs.viewBox === '0 0 16 16' && svg.attrs['stroke-width'] === '1.5' &&
|
|
1472
|
+
svg.attrs['stroke-linecap'] === 'round' && svg.attrs.width === '14')
|
|
1473
|
+
assert(
|
|
1474
|
+
'头部所有图标同规范(viewBox 16×16 / 14px / stroke 1.5 / round)',
|
|
1475
|
+
uniform,
|
|
1476
|
+
headIcons.map((s) => `${s.attrs.width}px,vb=${s.attrs.viewBox},sw=${s.attrs['stroke-width']}`).join(' | ') || '没有 SVG 图标',
|
|
1477
|
+
)
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const settingsSheet = Array.from(walk(panel)).find((n) => n.className === 'dsh-sel-sheet')
|
|
1481
|
+
assert('抽屉默认是收起的(data-open=0)', !!settingsSheet && settingsSheet.getAttribute('data-open') === '0',
|
|
1482
|
+
settingsSheet ? String(settingsSheet.getAttribute('data-open')) : '未找到抽屉')
|
|
1483
|
+
|
|
1484
|
+
// 模型下拉的清单来自 harness 的 /models 桩:默认是**空**的(免得跑用例依赖真实 host),
|
|
1485
|
+
// 所以这里先装一份夹具,再开抽屉 —— 否则下拉里只有一行「跟随主会话」。
|
|
1486
|
+
globalThis.__modelCatalogFixture = {
|
|
1487
|
+
ok: true,
|
|
1488
|
+
// current = **当前会话**在用的模型(host 按 sessionId 解析出来给客户端)
|
|
1489
|
+
current: { provider: 'p1', model: 'm-fast' },
|
|
1490
|
+
stages: { chat: 'high', translation: 'low', detail: 'off' },
|
|
1491
|
+
models: [
|
|
1492
|
+
{ provider: 'p1', providerName: 'Provider One', model: 'm-fast', name: 'Fast Model', efforts: [], defaultEffort: 'high' },
|
|
1493
|
+
{ provider: 'p1', providerName: 'Provider One', model: 'm-strong', name: 'Strong Model', efforts: [], defaultEffort: 'high' },
|
|
1494
|
+
{ provider: 'p2', providerName: 'Provider Two', model: 'm-other', name: 'Other Model', efforts: [], defaultEffort: null },
|
|
1495
|
+
],
|
|
1496
|
+
}
|
|
1497
|
+
// 这套用例打的是**真实 host** 的 /settings,会真的落盘。先清一次覆盖,
|
|
1498
|
+
// 保证不管上一次跑成什么样(哪怕中途崩了),这次都从默认值开始 —— 用例要能独立重跑。
|
|
1499
|
+
await fetch(ORIGIN + '/selection-explain/api/settings', {
|
|
1500
|
+
method: 'POST',
|
|
1501
|
+
headers: { 'content-type': 'application/json' },
|
|
1502
|
+
body: JSON.stringify({ reset: true }),
|
|
1503
|
+
})
|
|
1504
|
+
|
|
1505
|
+
// 点一下:抽屉打开 + 去 host 拉 spec/当前值
|
|
1506
|
+
settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1507
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
1508
|
+
assert('点「设置」把抽屉打开', settingsSheet.getAttribute('data-open') === '1',
|
|
1509
|
+
String(settingsSheet.getAttribute('data-open')))
|
|
1510
|
+
|
|
1511
|
+
// 等 host 把 spec 拉回来(真实请求,给足时间)
|
|
1512
|
+
for (let i = 0; i < 60 && !Array.from(walk(settingsSheet)).some((n) => n.className === 'dsh-sel-railbtn'); i += 1) {
|
|
1513
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
1514
|
+
}
|
|
1515
|
+
const settingsRail = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-rail')
|
|
1516
|
+
const railButtons = settingsRail ? Array.from(walk(settingsRail)).filter((n) => n.className === 'dsh-sel-railbtn') : []
|
|
1517
|
+
assert(
|
|
1518
|
+
'抽屉里按 host 下发的 spec 画出 6 个分类(模型/解读/背景/联网/界面/数据)',
|
|
1519
|
+
railButtons.length === 6 && railButtons.some((b) => textOf(b).indexOf('模型') >= 0),
|
|
1520
|
+
railButtons.map((b) => textOf(b)).join(' / ') || '一个都没有',
|
|
1521
|
+
)
|
|
1522
|
+
const settingsRows = Array.from(walk(settingsSheet)).filter((n) => n.className === 'dsh-sel-srow')
|
|
1523
|
+
assert(
|
|
1524
|
+
'分类设置均呈现,移除的控件不再出现',
|
|
1525
|
+
settingsRows.length >= 20 && !settingsRows.some(r => /输出上限|采样温度|缓存有效期|追问模型/.test(textOf(r))),
|
|
1526
|
+
`rows=${settingsRows.length}`,
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
// 切分类:只有当前分类的 pane 可见(其余 display:none)
|
|
1530
|
+
{
|
|
1531
|
+
const toolsTab = railButtons.find((b) => textOf(b).indexOf('联网') >= 0)
|
|
1532
|
+
toolsTab.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1533
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1534
|
+
const panes = Array.from(walk(settingsSheet)).filter((n) => n.className === 'dsh-sel-grp')
|
|
1535
|
+
const visible = panes.filter((p) => p.style.display !== 'none')
|
|
1536
|
+
assert(
|
|
1537
|
+
'点分类只切出这一类(其余收起)',
|
|
1538
|
+
visible.length === 1 && visible[0].getAttribute('data-pane') === 'tools',
|
|
1539
|
+
panes.map((p) => p.getAttribute('data-pane') + ':' + (p.style.display === 'none' ? 'off' : 'on')).join(' | '),
|
|
1540
|
+
)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// 改一项开关 → 底部动作栏报"未保存"(不弹 toast、不打断)
|
|
1544
|
+
{
|
|
1545
|
+
const sw = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sws')
|
|
1546
|
+
const note = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sfootnote')
|
|
1547
|
+
assert('开关与底部状态栏都在', !!sw && !!note, sw ? 'ok' : '没有开关')
|
|
1548
|
+
const before = sw.getAttribute('aria-checked')
|
|
1549
|
+
sw.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1550
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1551
|
+
assert(
|
|
1552
|
+
'改一项后立刻标脏(底部报未保存)',
|
|
1553
|
+
sw.getAttribute('aria-checked') !== before && note.getAttribute('data-dirty') === '1' &&
|
|
1554
|
+
/未保存/.test(textOf(note)),
|
|
1555
|
+
`checked=${before}→${sw.getAttribute('aria-checked')} dirty=${note.getAttribute('data-dirty')} "${textOf(note)}"`,
|
|
1556
|
+
)
|
|
1557
|
+
const settingInput = label => Array.from(walk(settingsSheet)).find(n => n.getAttribute('aria-label') === label)
|
|
1558
|
+
assert('关闭会话消息开关后,三个上下文输入框禁用',
|
|
1559
|
+
['首轮上下文消息数', '次轮与追问上下文消息数', '上下文总字符上限'].every(label => settingInput(label)?.disabled === true))
|
|
1560
|
+
assert('解读的会话消息开关不影响引用轮数', settingInput('上下文轮数')?.disabled !== true)
|
|
1561
|
+
// 防抖 400ms 后才真的落盘;这里等它发完并回填,避免污染后面用例的状态
|
|
1562
|
+
await new Promise((r) => setTimeout(r, 700))
|
|
1563
|
+
assert('保存完成后底部回到"改动会自动保存"', /改动会自动保存/.test(textOf(note)), `"${textOf(note)}"`)
|
|
1564
|
+
// 改回原值(用户环境不该被测试改掉)
|
|
1565
|
+
sw.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1566
|
+
await new Promise((r) => setTimeout(r, 700))
|
|
1567
|
+
assert('重新开启后,上下文输入框恢复可改', settingInput('首轮上下文消息数')?.disabled === false)
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// 模型:一个下拉同时定 provider + model(不再让用户自己拼两个 id)
|
|
1571
|
+
{
|
|
1572
|
+
railButtons.find((b) => textOf(b).indexOf('模型') >= 0).dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1573
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1574
|
+
const pick = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spick')
|
|
1575
|
+
assert('模型是自绘下拉的按钮(不是原生 <select>)', !!pick && !Array.from(walk(settingsSheet)).some((n) => n.tagName === 'SELECT'),
|
|
1576
|
+
pick ? 'button' : '未找到')
|
|
1577
|
+
|
|
1578
|
+
// 清单是异步拉的,等它填进来
|
|
1579
|
+
for (let i = 0; i < 80 && Array.from(walk(settingsSheet)).filter((n) => n.className === 'dsh-sel-spickrow').length === 0; i += 1) {
|
|
1580
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
1581
|
+
}
|
|
1582
|
+
// 列表默认藏着,点按钮才弹出来
|
|
1583
|
+
const menu = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spickmenu')
|
|
1584
|
+
assert('下拉菜单默认收起(data-open=0)', !!menu && menu.getAttribute('data-open') === '0',
|
|
1585
|
+
menu ? String(menu.getAttribute('data-open')) : '未找到菜单')
|
|
1586
|
+
pick.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1587
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1588
|
+
assert('点按钮把下拉弹出来', menu.getAttribute('data-open') === '1', String(menu.getAttribute('data-open')))
|
|
1589
|
+
|
|
1590
|
+
// 三个推理档位按**实际发生顺序**命名:首轮(翻译/解读/注释)→ 次轮详解 → 追问。
|
|
1591
|
+
// 顺序本身是信息(用户是照着"小窗先出什么、再出什么"理解这三档的),别改成字母序或历史序。
|
|
1592
|
+
{
|
|
1593
|
+
const modelPane = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-grp' && n.getAttribute('data-pane') === 'model')
|
|
1594
|
+
const labels = Array.from(walk(modelPane))
|
|
1595
|
+
.filter((n) => n.className === 'dsh-sel-srowt')
|
|
1596
|
+
.map((n) => textOf(n))
|
|
1597
|
+
const first = labels.findIndex((t) => t.indexOf('首轮') >= 0)
|
|
1598
|
+
const second = labels.findIndex((t) => t.indexOf('次轮') >= 0)
|
|
1599
|
+
const chat = labels.findIndex((t) => t === '追问')
|
|
1600
|
+
assert('推理三档按「首轮 → 次轮 → 追问」排列',
|
|
1601
|
+
first >= 0 && second > first && chat > second,
|
|
1602
|
+
labels.join(' / '))
|
|
1603
|
+
assert('首轮说明包含翻译、解读与注释', textOf(modelPane).includes('翻译 / 解读 / 注释'), textOf(modelPane))
|
|
1604
|
+
assert('次轮说明对应展开详解', textOf(modelPane).includes('点击「展开详解」'), textOf(modelPane))
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// 默认项写「跟随主会话(当前:…)」:括号里必须是**当前会话**在用的那个模型。
|
|
1608
|
+
// host 侧 resolveRoute 现在是会话级的(读会话事件流里的 model/selection),
|
|
1609
|
+
// 所以两个会话用不同模型时各自看到的也不同 —— 报的就是自己那一个。
|
|
1610
|
+
assert('默认项写明「跟随主会话」并带出当前会话的模型',
|
|
1611
|
+
textOf(pick).indexOf('跟随主会话') >= 0 && textOf(pick).indexOf('当前') >= 0,
|
|
1612
|
+
textOf(pick))
|
|
1613
|
+
assert('括号里的模型来自会话级 /models(夹具的 current,不是全局猜测)',
|
|
1614
|
+
textOf(pick).indexOf('Fast Model') >= 0,
|
|
1615
|
+
textOf(pick))
|
|
1616
|
+
|
|
1617
|
+
const rows = Array.from(walk(settingsSheet)).filter((n) => n.className === 'dsh-sel-spickrow')
|
|
1618
|
+
assert('下拉里列出了模型清单 + 一个「跟随主会话」', rows.length >= 4 &&
|
|
1619
|
+
textOf(rows[0]).indexOf('跟随主会话') >= 0, `rows=${rows.length} first="${rows[0] ? textOf(rows[0]) : ''}"`)
|
|
1620
|
+
|
|
1621
|
+
// 用户报的 bug:42 个模型时下拉没有滚动条。
|
|
1622
|
+
// 关键就是列表自己有 max-height + overflow:auto —— 断言这条 CSS 契约,防止被改回"整页顶出去"。
|
|
1623
|
+
const cssSrc = readFileSync(resolve(HERE, '..', 'src', 'client', 'index.js'), 'utf8')
|
|
1624
|
+
assert('下拉列表自带 max-height + overflow(滚动条是确定的)',
|
|
1625
|
+
/\.dsh-sel-spicklist\{[^}]*max-height:\d+px/.test(cssSrc) && /\.dsh-sel-spicklist\{[^}]*overflow-y:auto/.test(cssSrc),
|
|
1626
|
+
'.dsh-sel-spicklist 缺少 max-height 或 overflow-y:auto')
|
|
1627
|
+
assert('下拉菜单挂在抽屉上(不是行里,避开滚动区的裁剪)',
|
|
1628
|
+
!!menu && menu.parentNode === settingsSheet,
|
|
1629
|
+
menu ? (menu.parentNode ? menu.parentNode.className : '无父节点') : '未找到')
|
|
1630
|
+
|
|
1631
|
+
// 选一个模型 → 应该同时写 provider 与 model 两个键
|
|
1632
|
+
const target = rows.find((r) => textOf(r).indexOf('Fast Model') >= 0)
|
|
1633
|
+
target.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1634
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1635
|
+
const after = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
1636
|
+
assert('选一个模型 = 同时写入 provider 与 model',
|
|
1637
|
+
after.values.provider === 'p1' && after.values.model === 'm-fast',
|
|
1638
|
+
`provider=${after.values.provider} model=${after.values.model}`)
|
|
1639
|
+
assert('选完下拉自动收起', menu.getAttribute('data-open') === '0', String(menu.getAttribute('data-open')))
|
|
1640
|
+
assert('选中的那一行打勾', target.getAttribute('data-on') === '1', String(target.getAttribute('data-on')))
|
|
1641
|
+
|
|
1642
|
+
// 两处模型选择共用一份持久配置。
|
|
1643
|
+
{
|
|
1644
|
+
const picks = Array.from(walk(settingsSheet)).filter(n => n.className === 'dsh-sel-spick')
|
|
1645
|
+
assert('设置页只保留一个模型选择', picks.length === 1, String(picks.length))
|
|
1646
|
+
assert('设置选择立即更新输入框模型', hook.modelState().choice?.model === 'm-fast', JSON.stringify(hook.modelState().choice))
|
|
1647
|
+
await hook.loadModels(true)
|
|
1648
|
+
const composerRows = () => Array.from(walk(hook.modelNodes().menu)).filter(n => n.className === 'dsh-sel-pickerrow')
|
|
1649
|
+
composerRows().find(r => textOf(r).includes('Strong Model')).dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1650
|
+
assert('输入框选择立即更新设置模型', textOf(pick).includes('Strong Model'), textOf(pick))
|
|
1651
|
+
await new Promise(r => setTimeout(r, 900))
|
|
1652
|
+
const shared = await (await fetch(ORIGIN + '/selection-explain/api/settings')).json()
|
|
1653
|
+
assert('输入框选择持久保存到统一模型', shared.values.provider === 'p1' && shared.values.model === 'm-strong', JSON.stringify(shared.values))
|
|
1654
|
+
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// 恢复默认:所有参数**立刻**回到默认并显示出来(含两行模型下拉)。
|
|
1658
|
+
// 这里专挑"复合键"踩过的坑:模型行的条目 key 是虚拟的,服务端返回的却是 provider/model,
|
|
1659
|
+
// 早先按返回键逐个查条目会查不到 —— 于是恢复默认后模型下拉纹丝不动。
|
|
1660
|
+
{
|
|
1661
|
+
// 先改几项,制造"非默认"状态
|
|
1662
|
+
const pick0 = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spick')
|
|
1663
|
+
pick0.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1664
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
1665
|
+
const rows0 = Array.from(walk(settingsSheet)).filter((n) => n.className === 'dsh-sel-spickrow')
|
|
1666
|
+
rows0.find((r) => textOf(r).indexOf('Fast Model') >= 0).dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1667
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1668
|
+
assert('(前提)已改成一个具体模型', textOf(pick0).indexOf('Fast Model') >= 0, textOf(pick0))
|
|
1669
|
+
|
|
1670
|
+
const resetBtn = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sbtn' && textOf(n).indexOf('恢复默认') >= 0)
|
|
1671
|
+
resetBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1672
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1673
|
+
|
|
1674
|
+
const afterReset = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
1675
|
+
assert('恢复默认:服务端全部回到默认',
|
|
1676
|
+
afterReset.values.provider === '' && afterReset.values.model === '' && hook.modelState().choice === null,
|
|
1677
|
+
`provider=${afterReset.values.provider} chatProvider=${afterReset.values.chatProvider}`)
|
|
1678
|
+
// 只看"是不是回到了跟随态":括号里出现 Fast Model 是正常的 ——
|
|
1679
|
+
// 那正是本会话当前在用的模型(夹具的 current),跟随态就该把它报出来。
|
|
1680
|
+
assert('恢复默认:模型下拉**立刻**显示回「跟随」(不是停在刚才选的)',
|
|
1681
|
+
textOf(pick0).indexOf('跟随主会话') >= 0,
|
|
1682
|
+
textOf(pick0))
|
|
1683
|
+
// 数字/开关也要回到默认:随便挑一个数字输入框比对
|
|
1684
|
+
const ttlRow = Array.from(walk(settingsSheet)).find((r) =>
|
|
1685
|
+
r.className === 'dsh-sel-srow' && !!Array.from(walk(r)).find((n) => n.attrs && n.attrs['aria-label'] === '生成时限'))
|
|
1686
|
+
const ttlInput = ttlRow ? Array.from(walk(ttlRow)).find((n) => n.className === 'dsh-sel-snum') : null
|
|
1687
|
+
assert('恢复默认:数字项也回到默认值(600 秒)',
|
|
1688
|
+
!!ttlInput && String(ttlInput.value) === String(afterReset.values.timeoutMs / 1000),
|
|
1689
|
+
ttlInput ? `${ttlInput.value} vs ${afterReset.values.timeoutMs / 1000}` : '未找到')
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// 真·实时:抽屉**开着不动**,主会话换了模型 → 括号里的名字自己跟着变。
|
|
1693
|
+
// (这条是用户报的"还是没有实现"的核心:以前只在开抽屉那一下取一次。)
|
|
1694
|
+
{
|
|
1695
|
+
const pickEl = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spick')
|
|
1696
|
+
const before = textOf(pickEl)
|
|
1697
|
+
const fixture = globalThis.__modelCatalogFixture
|
|
1698
|
+
globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p1', model: 'm-strong' } }
|
|
1699
|
+
// 轮询间隔 2000ms,给够两拍
|
|
1700
|
+
for (let i = 0; i < 120 && textOf(pickEl).indexOf('Strong Model') < 0; i += 1) {
|
|
1701
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1702
|
+
}
|
|
1703
|
+
assert('抽屉开着不动,主会话换模型后括号自己跟着变(轮询生效)',
|
|
1704
|
+
textOf(pickEl).indexOf('Strong Model') >= 0 && textOf(pickEl) !== before,
|
|
1705
|
+
`之前"${before}" → 现在"${textOf(pickEl)}"`)
|
|
1706
|
+
globalThis.__modelCatalogFixture = fixture
|
|
1707
|
+
for (let i = 0; i < 120 && textOf(pickEl).indexOf('Fast Model') < 0; i += 1) {
|
|
1708
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// 实时性:小窗属于**划词那一刻的会话**,且每次开抽屉都要重取模型。
|
|
1713
|
+
// 否则切到别的会话 / 主会话中途换模型之后,括号里还停在第一次渲染的那个(用户报的问题)。
|
|
1714
|
+
{
|
|
1715
|
+
const src = readFileSync(resolve(HERE, '..', 'src', 'client', 'index.js'), 'utf8')
|
|
1716
|
+
assert('小窗记住"划词那一刻的会话"(不跟着 currentSessionId 乱跑)',
|
|
1717
|
+
/var panelSessionId = ''/.test(src) && /panelSessionId = currentSessionId\(\)/.test(src),
|
|
1718
|
+
'没有 panelSessionId 或其捕获点')
|
|
1719
|
+
assert('开抽屉时强制重取模型(绕过 60 秒缓存)',
|
|
1720
|
+
/refreshModelRow\(settingsByKey\.modelChoice, true\)/.test(src) &&
|
|
1721
|
+
/loadModelCatalog\(!!forceRoute, sessionId\)/.test(src),
|
|
1722
|
+
'openSettings 没有触发强制刷新')
|
|
1723
|
+
// 实时性:主会话随时能换模型,插件这边没有可订阅的事件 —— **小窗或抽屉任意一个开着**
|
|
1724
|
+
// 就轮询那条轻量 /route(跟随态下输入框那枚胶囊报的就是这个值)
|
|
1725
|
+
assert('小窗/抽屉开着时轮询 /route(实时跟着会话模型变)',
|
|
1726
|
+
/function startRouteWatch/.test(src) && /ROUTE_WATCH_MS = \d+/.test(src) &&
|
|
1727
|
+
/if \(!settingsOpen && !panelOpen\) \{ stopRouteWatch\(\); return \}/.test(src),
|
|
1728
|
+
'没有轮询或没有按"小窗+抽屉"判停')
|
|
1729
|
+
assert('两处都收了才停轮询(不空转打接口)',
|
|
1730
|
+
/function syncRouteWatch\(\)/.test(src) &&
|
|
1731
|
+
/if \(!settingsOpen && !panelOpen\) \{ stopRouteWatch\(\); return \}/.test(src) &&
|
|
1732
|
+
!/settingsOpen = false\n stopRouteWatch\(\)/.test(src),
|
|
1733
|
+
'closeSettings 仍无条件停表(小窗开着时胶囊就不跟了)')
|
|
1734
|
+
assert('跟随值的更新是独立的一处(抽屉那行没渲染出来也不影响胶囊)',
|
|
1735
|
+
/function applyFollowRoute\(route\)/.test(src) && /fetchRoute\([^)]*\)\.then\(applyFollowRoute\)/.test(src),
|
|
1736
|
+
'胶囊的更新仍绑在抽屉那一行上')
|
|
1737
|
+
assert('按本小窗所属会话取清单,且快照 current(不共享单例)',
|
|
1738
|
+
/panelSessionId \|\| currentSessionId\(\)/.test(src) && /current: catalog\.current/.test(src),
|
|
1739
|
+
'会话来源或快照写法不对')
|
|
1740
|
+
// 显示与实际必须同一个会话:解读/追问/升格三处请求也要用面板所属会话,
|
|
1741
|
+
// 否则"括号里显示 A 的模型、实际按 B 解析",又变成报错。
|
|
1742
|
+
const requestSites = (src.match(/sessionId: panelOrCurrentSessionId\(\)/g) || []).length
|
|
1743
|
+
assert('解读 / 追问 / 升格三处请求都按面板所属会话发',
|
|
1744
|
+
requestSites === 3, `找到 ${requestSites} 处(应为 3)`)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// 选回「跟随主会话」,别动用户环境
|
|
1748
|
+
pick.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1749
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1750
|
+
rows[0].dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1751
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1752
|
+
const restored = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
1753
|
+
assert('选回「跟随主会话」把 provider/model 都清空',
|
|
1754
|
+
restored.values.provider === '' && restored.values.model === '',
|
|
1755
|
+
`provider=${restored.values.provider} model=${restored.values.model}`)
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// 行为验证(用户报的"没有实时更新"):换掉会话的模型 → 重开抽屉 → 括号里必须跟着变。
|
|
1759
|
+
{
|
|
1760
|
+
// pick 是上一个块里的 const,出了块就没了 —— 这里重新按类名取一次
|
|
1761
|
+
const pickEl = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-spick')
|
|
1762
|
+
const backBtn = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sheetback')
|
|
1763
|
+
const labelBefore = textOf(pickEl)
|
|
1764
|
+
// 主会话把模型换成另一个(夹具的 current 就是"本会话在用的模型")
|
|
1765
|
+
const fixture = globalThis.__modelCatalogFixture
|
|
1766
|
+
globalThis.__modelCatalogFixture = {
|
|
1767
|
+
...fixture,
|
|
1768
|
+
current: { provider: 'p2', model: 'm-other' },
|
|
1769
|
+
}
|
|
1770
|
+
backBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1771
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
1772
|
+
settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1773
|
+
for (let i = 0; i < 80 && textOf(pickEl).indexOf('Other Model') < 0; i += 1) {
|
|
1774
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
1775
|
+
}
|
|
1776
|
+
assert('重开抽屉后括号里的模型跟着会话变(不是停在第一次渲染)',
|
|
1777
|
+
textOf(pickEl).indexOf('Other Model') >= 0 && textOf(pickEl) !== labelBefore,
|
|
1778
|
+
`之前"${labelBefore}" → 现在"${textOf(pickEl)}"`)
|
|
1779
|
+
globalThis.__modelCatalogFixture = fixture // 还原,别影响后面的用例
|
|
1780
|
+
backBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1781
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
1782
|
+
settingsBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1783
|
+
for (let i = 0; i < 80 && textOf(pickEl).indexOf('Fast Model') < 0; i += 1) {
|
|
1784
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// 用户报的"输入框那枚模型胶囊没有及时刷新":抽屉**关着**、小窗开着时也要跟。
|
|
1789
|
+
// 跟随态下胶囊报的就是"本会话在用的模型",而以前只在抽屉开着时轮询 ——
|
|
1790
|
+
// 于是"小窗开着、主会话换了模型"要等下一次开/关设置页才更新。
|
|
1791
|
+
{
|
|
1792
|
+
const backBtn = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sheetback')
|
|
1793
|
+
backBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1794
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
1795
|
+
assert('(前提)抽屉收起、小窗还开着、模型是跟随态',
|
|
1796
|
+
settingsSheet.getAttribute('data-open') === '0' && panel.style.display === 'flex' && hook.modelState().choice === null,
|
|
1797
|
+
`sheet=${settingsSheet.getAttribute('data-open')} panel=${panel.style.display} choice=${JSON.stringify(hook.modelState().choice)}`)
|
|
1798
|
+
|
|
1799
|
+
const fixture = globalThis.__modelCatalogFixture
|
|
1800
|
+
globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p1', model: 'm-fast' } }
|
|
1801
|
+
// 跟随态显示的是清单里的**显示名**(p1/m-fast → Fast Model),不是裸 id
|
|
1802
|
+
for (let i = 0; i < 120 && hook.modelState().pill.indexOf('Fast Model') < 0; i += 1) {
|
|
1803
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1804
|
+
}
|
|
1805
|
+
assert('抽屉关着:胶囊报出跟随的模型(开窗即对齐,不等第一拍轮询)',
|
|
1806
|
+
hook.modelState().pill.indexOf('Fast Model') >= 0,
|
|
1807
|
+
`pill="${hook.modelState().pill}"`)
|
|
1808
|
+
|
|
1809
|
+
// 主会话换模型 —— 一次设置页都不用碰
|
|
1810
|
+
globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p2', model: 'm-other' } }
|
|
1811
|
+
for (let i = 0; i < 120 && hook.modelState().pill.indexOf('Other Model') < 0; i += 1) {
|
|
1812
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1813
|
+
}
|
|
1814
|
+
assert('小窗开着、抽屉关着:主会话换模型后胶囊自己跟着变(轮询生效)',
|
|
1815
|
+
hook.modelState().pill.indexOf('Other Model') >= 0,
|
|
1816
|
+
`pill="${hook.modelState().pill}"`)
|
|
1817
|
+
|
|
1818
|
+
// 小窗收起来 → 轮询停:夹具再换值,胶囊不该跟着动(不空转打接口)
|
|
1819
|
+
hook.close()
|
|
1820
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
1821
|
+
globalThis.__modelCatalogFixture = { ...fixture, current: { provider: 'p1', model: 'm-fast' } }
|
|
1822
|
+
await new Promise((r) => setTimeout(r, 2600))
|
|
1823
|
+
assert('小窗与抽屉都收了:轮询停下(值不再跟着变)',
|
|
1824
|
+
hook.modelState().pill.indexOf('Other Model') >= 0,
|
|
1825
|
+
`pill="${hook.modelState().pill}"`)
|
|
1826
|
+
globalThis.__modelCatalogFixture = fixture
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// 用户报的"为什么显示 deepseek-flash":同一个 model id 在**多个 provider** 下名字不同
|
|
1830
|
+
// (实测 deepseek-flash 在 opencode-go 叫「DeepSeek Flash」、在 deepseek-official 叫
|
|
1831
|
+
// 「DeepSeek-V41-Flash」)—— 跟随态必须按 provider + model 两个键换成显示名:
|
|
1832
|
+
// 既不能显示裸 id,也不能取到别的 provider 的名字。
|
|
1833
|
+
// 这条同时验"开窗顺手拉清单":切到另一个会话(清单缓存按会话隔离)后只开窗、不碰模型菜单,
|
|
1834
|
+
// 胶囊也该自己变成显示名。
|
|
1835
|
+
{
|
|
1836
|
+
const fixture = globalThis.__modelCatalogFixture
|
|
1837
|
+
globalThis.__modelCatalogFixture = {
|
|
1838
|
+
ok: true,
|
|
1839
|
+
current: { provider: 'p2', model: 'shared-id' },
|
|
1840
|
+
stages: { chat: 'high' },
|
|
1841
|
+
models: [
|
|
1842
|
+
{ provider: 'p1', providerName: 'P1', model: 'shared-id', name: 'P1 的那个名字', efforts: [], defaultEffort: null },
|
|
1843
|
+
{ provider: 'p2', providerName: 'DeepSeek', model: 'shared-id', name: 'DeepSeek-V41-Flash', efforts: [], defaultEffort: null },
|
|
1844
|
+
],
|
|
1845
|
+
}
|
|
1846
|
+
// 换个会话:清单缓存按会话隔离,这样开窗那一下必须真去拉一次(而不是复用旧缓存)
|
|
1847
|
+
globalThis.__sessionStubId = 'session-stub-2'
|
|
1848
|
+
hook.open('同名模型探测', '', '同名模型')
|
|
1849
|
+
for (let i = 0; i < 120 && hook.modelState().pill.indexOf('DeepSeek-V41-Flash') < 0; i += 1) {
|
|
1850
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1851
|
+
}
|
|
1852
|
+
assert('跟随态显示清单里的显示名,不再是裸 id',
|
|
1853
|
+
hook.modelState().pill.indexOf('DeepSeek-V41-Flash') >= 0 && hook.modelState().pill.indexOf('shared-id') < 0,
|
|
1854
|
+
`pill="${hook.modelState().pill}"`)
|
|
1855
|
+
assert('同一个 model id 不会取到别的 provider 的名字(按 provider + model 查)',
|
|
1856
|
+
hook.modelState().pill.indexOf('P1 的那个名字') < 0,
|
|
1857
|
+
`pill="${hook.modelState().pill}"`)
|
|
1858
|
+
assert('胶囊 tooltip 带上 provider(同名 id 靠它区分)',
|
|
1859
|
+
String(hook.modelNodes().pill.title || '').indexOf('DeepSeek') >= 0,
|
|
1860
|
+
`title="${hook.modelNodes().pill.title}"`)
|
|
1861
|
+
globalThis.__sessionStubId = 'session-stub-1'
|
|
1862
|
+
globalThis.__modelCatalogFixture = fixture
|
|
1863
|
+
hook.close()
|
|
1864
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
// 保存后的设置必须影响入口、状态胶囊和实际选区处理,而不只改变显示值。
|
|
1869
|
+
{
|
|
1870
|
+
const inputFor = label => Array.from(walk(settingsSheet)).find(n => n.getAttribute('aria-label') === label)
|
|
1871
|
+
const setNumber = async (label, value) => {
|
|
1872
|
+
const input = inputFor(label)
|
|
1873
|
+
input.value = String(value)
|
|
1874
|
+
input.dispatch('input', { stopPropagation() {}, preventDefault() {} })
|
|
1875
|
+
await new Promise(r => setTimeout(r, 700))
|
|
1876
|
+
}
|
|
1877
|
+
const clickSwitch = async label => {
|
|
1878
|
+
inputFor(label).dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1879
|
+
await new Promise(r => setTimeout(r, 700))
|
|
1880
|
+
}
|
|
1881
|
+
await setNumber('选中文字上限', 6000)
|
|
1882
|
+
hook.close()
|
|
1883
|
+
const originalText = selection.toString
|
|
1884
|
+
selection.toString = () => '长'.repeat(4500)
|
|
1885
|
+
documentStub.dispatch('mouseup', { target: body })
|
|
1886
|
+
await new Promise(r => setTimeout(r, 20))
|
|
1887
|
+
assert('选中文字上限调到 6000 后,4500 字选区能显示解读入口', button.style.display === 'inline-flex')
|
|
1888
|
+
await setNumber('选中文字上限', 20)
|
|
1889
|
+
selection.toString = () => '长'.repeat(21)
|
|
1890
|
+
documentStub.dispatch('mouseup', { target: body })
|
|
1891
|
+
await new Promise(r => setTimeout(r, 20))
|
|
1892
|
+
assert('选中文字上限调到 20 后,21 字选区不显示入口', button.style.display === 'none')
|
|
1893
|
+
await setNumber('选中文字上限', 4000)
|
|
1894
|
+
selection.toString = originalText
|
|
1895
|
+
|
|
1896
|
+
const frame = new FakeEl('iframe')
|
|
1897
|
+
frame.setAttribute('data-html-preview', 'true'); frame.setAttribute('sandbox', ''); frame.setAttribute('srcdoc', '<p>入口测试</p>')
|
|
1898
|
+
frame.isConnected = true; frame.contentWindow = {name:'settings-bridge-test'}
|
|
1899
|
+
const oldQuery = documentStub.querySelectorAll
|
|
1900
|
+
documentStub.querySelectorAll = selector => selector === 'iframe' ? [frame] : []
|
|
1901
|
+
hook.bridgeScan(); hook.reset()
|
|
1902
|
+
const reportSelection = () => windowStub.dispatch('message', {source:frame.contentWindow,data:{__dshSel:1,kind:'selection',sel:{text:'入口测试',context:'',rect:{x:10,y:20,right:100,bottom:38,w:90,h:18}}}})
|
|
1903
|
+
await clickSwitch('侧边栏网页划词'); reportSelection()
|
|
1904
|
+
assert('关闭侧边栏划词后,已有桥发来的选区也不打开入口', hook.bridge().on === false && hook.selection() === null)
|
|
1905
|
+
await clickSwitch('侧边栏网页划词'); reportSelection()
|
|
1906
|
+
assert('重新开启侧边栏划词后,帧内选区恢复入口', hook.bridge().on === true && hook.selection()?.source === 'iframe' && button.style.display === 'inline-flex')
|
|
1907
|
+
frame.isConnected = false; documentStub.querySelectorAll = oldQuery; hook.reset()
|
|
1908
|
+
|
|
1909
|
+
await clickSwitch('显示状态胶囊')
|
|
1910
|
+
assert('关闭状态胶囊保存后实际隐藏', hook.pill().style.display === 'none')
|
|
1911
|
+
await clickSwitch('显示状态胶囊')
|
|
1912
|
+
assert('开启状态胶囊保存后恢复显示', hook.pill().style.display !== 'none')
|
|
1913
|
+
const actualSetTimeout = globalThis.setTimeout
|
|
1914
|
+
const scheduledDelays = []
|
|
1915
|
+
globalThis.setTimeout = (fn, delay, ...args) => {
|
|
1916
|
+
scheduledDelays.push(delay)
|
|
1917
|
+
return actualSetTimeout(fn, delay, ...args)
|
|
1918
|
+
}
|
|
1919
|
+
try { await setNumber('自动收起时间', 5) }
|
|
1920
|
+
finally { globalThis.setTimeout = actualSetTimeout }
|
|
1921
|
+
assert('自动收起时间实际更新胶囊计时偏好', hook.pillState().idleMs === 5000)
|
|
1922
|
+
assert('修改自动收起时间后,已有计时立即按新时长重新安排', scheduledDelays.includes(5000))
|
|
1923
|
+
await setNumber('自动收起时间', 10)
|
|
1924
|
+
hook.open('settings-return', '', '设置验证')
|
|
1925
|
+
await new Promise(r=>setTimeout(r,80))
|
|
1926
|
+
settingsBtn.dispatch('click', {stopPropagation(){},preventDefault(){}})
|
|
1927
|
+
await new Promise(r=>setTimeout(r,20))
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// 时间字段按「秒」显示:配置里仍是毫秒(scale 只管显示)
|
|
1931
|
+
{
|
|
1932
|
+
railButtons.find((b) => textOf(b).indexOf('数据') >= 0).dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
1933
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1934
|
+
const units = Array.from(walk(settingsSheet))
|
|
1935
|
+
.filter((n) => n.className === 'dsh-sel-sunit')
|
|
1936
|
+
.map((n) => textOf(n))
|
|
1937
|
+
assert('时间字段的单位是「秒」而不是「毫秒」',
|
|
1938
|
+
units.filter((u) => u === '秒').length >= 2 && units.indexOf('毫秒') < 0,
|
|
1939
|
+
units.join(' / '))
|
|
1940
|
+
|
|
1941
|
+
// 生成时限按秒显示,host 仍存毫秒
|
|
1942
|
+
const cacheSpec = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
1943
|
+
const rowInput = (key) => {
|
|
1944
|
+
const rowEl = Array.from(walk(settingsSheet)).find((r) =>
|
|
1945
|
+
r.className === 'dsh-sel-srow' && !!Array.from(walk(r)).find((n) => n.attrs && n.attrs['aria-label'] === key))
|
|
1946
|
+
return rowEl ? Array.from(walk(rowEl)).find((n) => n.className === 'dsh-sel-snum') : null
|
|
1947
|
+
}
|
|
1948
|
+
const cacheInput = rowInput('生成时限')
|
|
1949
|
+
assert('生成时限的毫秒 → 秒换算正确',
|
|
1950
|
+
!!cacheInput && String(cacheInput.value) === String(cacheSpec.values.timeoutMs / 1000),
|
|
1951
|
+
cacheInput ? `${cacheInput.value} vs ${cacheSpec.values.timeoutMs / 1000}` : '未找到输入框')
|
|
1952
|
+
|
|
1953
|
+
// 在界面里改成 120 秒 → 配置里应存 120000 毫秒
|
|
1954
|
+
cacheInput.value = '120'
|
|
1955
|
+
cacheInput.dispatch('input', { stopPropagation() {}, preventDefault() {} })
|
|
1956
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1957
|
+
const saved = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
1958
|
+
assert('界面填 120 秒 = 配置存 120000 毫秒',
|
|
1959
|
+
saved.values.timeoutMs === 120000, String(saved.values.timeoutMs))
|
|
1960
|
+
// 还原(改回 600 秒)
|
|
1961
|
+
cacheInput.value = '300'
|
|
1962
|
+
cacheInput.dispatch('input', { stopPropagation() {}, preventDefault() {} })
|
|
1963
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// 收尾:这套断言打的是**真实 host** 的 /settings,会真的落盘到
|
|
1967
|
+
// ${DSH_HOME:-~/.dsh}/selection-explain/settings.json。跑完必须清掉覆盖,
|
|
1968
|
+
// 否则跑一次测试就在用户环境里留下一份 settings.json(值等于默认,但仍是脏状态)。
|
|
1969
|
+
{
|
|
1970
|
+
const resetRes = await fetch(ORIGIN + '/selection-explain/api/settings', {
|
|
1971
|
+
method: 'POST',
|
|
1972
|
+
headers: { 'content-type': 'application/json' },
|
|
1973
|
+
body: JSON.stringify({ reset: true }),
|
|
1974
|
+
})
|
|
1975
|
+
const resetBody = await resetRes.json().catch(() => null)
|
|
1976
|
+
assert('测试收尾:清掉设置覆盖,不留 settings.json 残留',
|
|
1977
|
+
!!resetBody && resetBody.ok === true && resetBody.values.historyMaxEntries === 20,
|
|
1978
|
+
JSON.stringify(resetBody && { ok: resetBody.ok, history: resetBody.values && resetBody.values.historyMaxEntries }))
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Esc 只退一层:先收抽屉,小窗留着
|
|
1982
|
+
{
|
|
1983
|
+
const before = panel.style.display
|
|
1984
|
+
documentStub.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
|
|
1985
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1986
|
+
assert(
|
|
1987
|
+
'Esc 先收抽屉、不关小窗(一次只退一层)',
|
|
1988
|
+
settingsSheet.getAttribute('data-open') === '0' && panel.style.display === before,
|
|
1989
|
+
`sheet=${settingsSheet.getAttribute('data-open')} panel=${panel.style.display}`,
|
|
1990
|
+
)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// 用户报的"模型列表上没有标示当前正在使用的模型":跟随态(没选过具体模型)下,输入框那枚
|
|
1994
|
+
// 模型菜单里**会话正在用的那个**也要打勾 —— 以前只认"自己选过的",跟随态一行都不勾。
|
|
1995
|
+
// 放在这里是因为:再往后的用例只在"本机 DSH 跑在 3080"时才执行,而 test-settings-host.mjs
|
|
1996
|
+
// 会在下面那个 exit 处收工 —— 放这儿才进得了 `npm test`。
|
|
1997
|
+
{
|
|
1998
|
+
const fixture = globalThis.__modelCatalogFixture
|
|
1999
|
+
globalThis.__modelCatalogFixture = {
|
|
2000
|
+
ok: true,
|
|
2001
|
+
current: { provider: 'p1', model: 'm-fast' },
|
|
2002
|
+
stages: { chat: 'high' },
|
|
2003
|
+
models: [
|
|
2004
|
+
{ provider: 'p1', providerName: 'P1', model: 'm-fast', name: 'Fast Model', efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' },
|
|
2005
|
+
{ provider: 'p1', providerName: 'P1', model: 'm-strong', name: 'Strong Model', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }], defaultEffort: 'high' },
|
|
2006
|
+
{ provider: 'p2', providerName: 'P2', model: 'm-other', name: 'Other Model', efforts: [], defaultEffort: null },
|
|
2007
|
+
],
|
|
2008
|
+
}
|
|
2009
|
+
hook.open('菜单勾选探测', '', '菜单勾选')
|
|
2010
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
2011
|
+
await hook.loadModels(true)
|
|
2012
|
+
const menu = hook.modelNodes().menu
|
|
2013
|
+
const rowsIn = () => Array.from(walk(menu)).filter((n) => String(n.className) === 'dsh-sel-pickerrow')
|
|
2014
|
+
const tiersIn = () => Array.from(walk(menu)).filter((n) => String(n.className) === 'dsh-sel-tierbtn')
|
|
2015
|
+
assert('(前提)跟随态:没选过具体模型', hook.modelState().choice === null, JSON.stringify(hook.modelState().choice))
|
|
2016
|
+
const on = rowsIn().filter((r) => r.getAttribute('data-on') === '1')
|
|
2017
|
+
assert('跟随态:清单里当前在用的模型也打勾(不再是"一行都不勾")',
|
|
2018
|
+
on.length === 1 && textOf(on[0]).indexOf('Fast Model') >= 0,
|
|
2019
|
+
on.map((r) => textOf(r)).join(' | ') || '没有任何一行打勾')
|
|
2020
|
+
assert('跟随态那一行带「跟随」小标(这个勾来自主会话,不是自己选的)',
|
|
2021
|
+
!!on[0] && !!Array.from(walk(on[0])).find((n) => String(n.className) === 'dsh-sel-pickerfollow' && textOf(n) === '跟随'),
|
|
2022
|
+
on[0] ? Array.from(walk(on[0])).map((n) => String(n.className) + ':' + textOf(n)).join(', ') : '未找到')
|
|
2023
|
+
assert('跟随态:档位按钮列的是**在用模型**声明的档位(m-fast 四档)',
|
|
2024
|
+
tiersIn().map((b) => textOf(b)).join('/') === '关/低/高/最大', tiersIn().map((b) => textOf(b)).join('/'))
|
|
2025
|
+
|
|
2026
|
+
// 选一个具体模型:勾挪到那一行、「跟随」小标消失、档位收缩成它声明的那几档
|
|
2027
|
+
const strong = rowsIn().find((r) => textOf(r).indexOf('Strong Model') >= 0)
|
|
2028
|
+
strong.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
2029
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
2030
|
+
const on2 = rowsIn().filter((r) => r.getAttribute('data-on') === '1')
|
|
2031
|
+
assert('选过之后:勾挪到选中的那一行',
|
|
2032
|
+
on2.length === 1 && textOf(on2[0]).indexOf('Strong Model') >= 0,
|
|
2033
|
+
on2.map((r) => textOf(r)).join(' | '))
|
|
2034
|
+
assert('选过之后:「跟随」小标消失(这个勾是你自己选的)',
|
|
2035
|
+
!Array.from(walk(menu)).some((n) => String(n.className) === 'dsh-sel-pickerfollow'), '')
|
|
2036
|
+
assert('选过之后:档位收缩成该模型声明的 2 档',
|
|
2037
|
+
tiersIn().map((b) => textOf(b)).join('/') === '关/高', tiersIn().map((b) => textOf(b)).join('/'))
|
|
2038
|
+
|
|
2039
|
+
// 还原:走抽屉里那颗「恢复默认」(客户端自己的路径,state 与 host 都回到跟随态)
|
|
2040
|
+
await new Promise((r) => setTimeout(r, 700))
|
|
2041
|
+
const resetBtn = Array.from(walk(settingsSheet)).find((n) => n.className === 'dsh-sel-sbtn' && textOf(n).indexOf('恢复默认') >= 0)
|
|
2042
|
+
resetBtn.dispatch('click', { stopPropagation() {}, preventDefault() {} })
|
|
2043
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
2044
|
+
const back = await (await fetch(ORIGIN + '/selection-explain/api/settings', { headers: { accept: 'application/json' } })).json()
|
|
2045
|
+
assert('(还原)恢复默认:host 与客户端都回到跟随态',
|
|
2046
|
+
back.values.provider === '' && back.values.model === '' && hook.modelState().choice === null,
|
|
2047
|
+
JSON.stringify({ provider: back.values.provider, model: back.values.model, choice: hook.modelState().choice }))
|
|
2048
|
+
globalThis.__modelCatalogFixture = fixture
|
|
2049
|
+
hook.close()
|
|
2050
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
if (process.env.SEL_SETTINGS_ONLY === '1') {
|
|
2054
|
+
console.log('=== 设置与模型集成测试结束(真实 host,临时数据目录)===')
|
|
2055
|
+
process.exit(process.exitCode ?? 0)
|
|
2056
|
+
}
|
|
1408
2057
|
// ───────────────────────── 升格为正式会话 ─────────────────────────
|
|
1409
2058
|
// 先走一遍两阶段(翻译 → 展开详解),确保升格时取的是两个阶段的结果而不是最后一段流
|
|
1410
2059
|
hook.open('stage-probe', '', '分阶段')
|
|
@@ -2493,7 +3142,9 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
|
|
|
2493
3142
|
const strong = rowsIn().find((r) => textOf(r).indexOf('Strong Model') >= 0)
|
|
2494
3143
|
strong.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
2495
3144
|
assert('点模型行 → 胶囊换成新模型名', nodes.pill.textContent.indexOf('Strong Model') >= 0, nodes.pill.textContent)
|
|
2496
|
-
|
|
3145
|
+
await new Promise(r => setTimeout(r, 700))
|
|
3146
|
+
const selectedSettings = await (await fetch(ORIGIN + '/selection-explain/api/settings')).json()
|
|
3147
|
+
assert('模型选择保存到统一设置', selectedSettings.values.provider === 'p1' && selectedSettings.values.model === 'm-strong', JSON.stringify(selectedSettings.values))
|
|
2497
3148
|
assert('菜单里的勾选跟着换', rowsIn().filter((r) => r.getAttribute('data-on') === '1').length === 1, '')
|
|
2498
3149
|
assert('档位按钮收缩成该模型支持的 2 档', tiersIn().map((b) => textOf(b)).join('/') === '关/高', tiersIn().map((b) => textOf(b)).join('/'))
|
|
2499
3150
|
|