@yfwu2020/dsh-selection-explain 0.8.3 → 0.9.1
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 +37 -0
- package/lib/client.js +427 -5
- package/lib/index.js +147 -7
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +323 -0
- package/scripts/test-client.mjs +74 -3
- package/scripts/test-escape-focus.mjs +66 -0
- package/src/client/index.js +427 -5
- package/src/index.ts +147 -8
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2876
|
-
text:
|
|
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`);
|