@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/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
- const requestEffort = isChat
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
- const canRetryEffort = isChat && bodyEffort.length > 0
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
- roundEffort = ''
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: bodyEffort,
3249
- text: `「${bodyEffort}」档位该模型不支持,已按默认档位重试`,
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`,