@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/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`,
|