@yfwu2020/dsh-selection-explain 0.4.1 → 0.7.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
@@ -36,6 +36,25 @@ import {
36
36
  /** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
37
37
  export const name = '@yfwu2020/dsh-selection-explain'
38
38
 
39
+ /**
40
+ * 升格时那条「上下文注入」消息的 `source.kind` —— 生产者**自有**的来源标识。
41
+ *
42
+ * 会话格式 v4 起(本机实测内核 0.2.0-rc.2),消息来源必须是生产者自报的 kind;
43
+ * v3 那种 `plugin` 包装(`kind: 'plugin'` 配一个 `plugin` 字段)已经退役,会被
44
+ * v4 准入当场拒掉:
45
+ * SessionFormatError: format v4 message requires a producer-owned source kind
46
+ * 症状极具迷惑性:`↗ 升格` 的 HTTP 请求返回 `ok: true`(那时只写进了内存里的会话面),
47
+ * 随后那轮 opener 运行失败、整个 artifact 一个字都没落盘 —— 新会话只剩表头。
48
+ * 离线测试也照样全绿(校验发生在真实内核里,不在构建期),所以另配了
49
+ * scripts/test-format.mjs 钉住这个 shape。
50
+ *
51
+ * 第三方插件的规范形态是 `plugin:<插件名>`:内核 v3→v4 迁移把那种 `plugin` 包装
52
+ * 重写成 `plugin:<完整插件名>`(`producerKind()` 对不在同名表里的插件就是这么算的),
53
+ * 所以这里写的与迁移结果逐字节一致 —— 新旧会话归因相同,且 v3 内核下同样合法
54
+ * (直写的 kind 迁移时原样保留)。
55
+ */
56
+ export const PLUGIN_SOURCE_KIND = `plugin:${name}`
57
+
39
58
  /** 路由注册与模型调用所需的服务。 */
40
59
  export const inject = ['webServer', 'llm']
41
60
 
@@ -1381,6 +1400,94 @@ export function apply(ctx: Context, rawConfig: Config): void {
1381
1400
  const historyFile = (): string =>
1382
1401
  join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'history.json')
1383
1402
 
1403
+ /**
1404
+ * 悬浮胶囊"拖到别处"后的位置(用户要求记住,存 host 上 —— 不用 localStorage)。
1405
+ * 只记自由摆放的**右边缘 + 垂直中心**:收球/展开都以右边缘为锚(client 半的 applyFreePill 用 CSS right 钉住)。
1406
+ * 吸附回右下角(anchor)时会清掉 —— 那是默认位置,不需要记。
1407
+ */
1408
+ const pillPosFile = (): string =>
1409
+ join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'pill.json')
1410
+
1411
+ let pillPos: { right: number; y: number } | null = null
1412
+ let pillPosLoaded = false
1413
+ let pillPosTimer: ReturnType<typeof setTimeout> | null = null
1414
+
1415
+ /** 位置校验:只接受有限数值,并夹到一个"任何屏幕都放得下"的量级。 */
1416
+ function normalizePillPos(input: unknown): { right: number; y: number } | null {
1417
+ const value = input as { right?: unknown; y?: unknown } | null | undefined
1418
+ const right = typeof value?.right === 'number' && Number.isFinite(value.right) ? value.right : NaN
1419
+ const y = typeof value?.y === 'number' && Number.isFinite(value.y) ? value.y : NaN
1420
+ if (!Number.isFinite(right) || !Number.isFinite(y)) return null
1421
+ const clamp = (n: number): number => Math.round(Math.min(40000, Math.max(-40000, n)))
1422
+ return { right: clamp(right), y: clamp(y) }
1423
+ }
1424
+
1425
+ async function loadPillPos(): Promise<void> {
1426
+ if (pillPosLoaded) return
1427
+ pillPosLoaded = true
1428
+ try {
1429
+ const parsed = JSON.parse(await readFile(pillPosFile(), 'utf8')) as { position?: unknown }
1430
+ pillPos = normalizePillPos(parsed?.position)
1431
+ } catch {
1432
+ pillPos = null // 没存过 / 坏了都当作"没记过"
1433
+ }
1434
+ }
1435
+
1436
+ function schedulePillPosWrite(): void {
1437
+ if (pillPosTimer) clearTimeout(pillPosTimer)
1438
+ pillPosTimer = setTimeout(() => {
1439
+ pillPosTimer = null
1440
+ void (async () => {
1441
+ try {
1442
+ await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true })
1443
+ await writeFile(pillPosFile(), JSON.stringify({ version: 1, position: pillPos }, null, 0), 'utf8')
1444
+ } catch (error) {
1445
+ ctx.logger?.warn?.(`[${name}] 写入胶囊位置失败:${String((error as Error)?.message ?? error)}`)
1446
+ }
1447
+ })()
1448
+ }, 300)
1449
+ pillPosTimer.unref?.()
1450
+ }
1451
+
1452
+ /**
1453
+ * 胶囊位置:GET 读回来(挂载时恢复)、POST 存({right,y})或清({clear:true},吸附回右下角时调)。
1454
+ */
1455
+ const handlePillPos = (req: IncomingMessage, res: ServerResponse): void => {
1456
+ void (async () => {
1457
+ await loadPillPos()
1458
+ if (req.method === 'GET') {
1459
+ sendJson(res, 200, { ok: true, position: pillPos })
1460
+ return
1461
+ }
1462
+ if (req.method !== 'POST') {
1463
+ sendJson(res, 405, { ok: false, error: '只支持 GET / POST' })
1464
+ return
1465
+ }
1466
+ const raw = await readBody(req, 4_000)
1467
+ let body: { position?: unknown; clear?: unknown } = {}
1468
+ try {
1469
+ body = raw ? (JSON.parse(raw) as { position?: unknown; clear?: unknown }) : {}
1470
+ } catch {
1471
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
1472
+ return
1473
+ }
1474
+ if (body.clear === true) {
1475
+ pillPos = null
1476
+ schedulePillPosWrite()
1477
+ sendJson(res, 200, { ok: true, position: null })
1478
+ return
1479
+ }
1480
+ const next = normalizePillPos(body.position ?? body)
1481
+ if (!next) {
1482
+ sendJson(res, 400, { ok: false, error: 'position 需要 { right, y } 两个有限数值' })
1483
+ return
1484
+ }
1485
+ pillPos = next
1486
+ schedulePillPosWrite()
1487
+ sendJson(res, 200, { ok: true, position: pillPos })
1488
+ })()
1489
+ }
1490
+
1384
1491
  const clampText = (value: unknown, max: number): string => (typeof value === 'string' ? value.slice(0, max) : '')
1385
1492
 
1386
1493
  async function loadHistory(): Promise<void> {
@@ -2039,7 +2146,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
2039
2146
 
2040
2147
  /**
2041
2148
  * 把划词解读小窗「升格」成正式会话(建在来源会话同一个项目下):
2042
- * ① 建会话 → ② 注入上下文消息(选中文字/所在位置/上下文片段,plugin 来源的 context 消息)
2149
+ * ① 建会话 → ② 注入上下文消息(选中文字/所在位置/上下文片段,生产者自有 kind 的 context 消息)
2043
2150
  * → ③ 把首轮解读写成助手消息 → ④ 把追问逐轮写成真实的 user/assistant 历史消息
2044
2151
  * → ⑤ 送一条短开场消息让 agent 接手(这样整段历史才会落盘)。
2045
2152
  * 这样新会话里看到的就是一段真实的多轮对话,而不是一大坨打包文本。
@@ -2178,7 +2285,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
2178
2285
  live.append?.('turn/end', { turn, reason: { kind: 'completed' } })
2179
2286
  }
2180
2287
 
2181
- // 第 1 轮:上下文消息(plugin 来源 + snapshot 呈现)+ 首轮解读
2288
+ // 第 1 轮:上下文消息(生产者自有的 source.kind + snapshot 呈现)+ 首轮解读
2289
+ // ⚠️ kind 必须是 PLUGIN_SOURCE_KIND(`plugin:<插件名>`):会话格式 v4 起不再
2290
+ // 接受退役的 `plugin` 包装(`kind: 'plugin'` 加一个 `plugin` 字段),否则整场升格
2291
+ // 会死在 "format v4 message requires a producer-owned source kind" 上(见文件头常量注释)。
2182
2292
  const contextBody = [
2183
2293
  `选中文字:${text}`,
2184
2294
  `所在位置:${label}`,
@@ -2193,8 +2303,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
2193
2303
  id: `sel-ctx-${stamp}`,
2194
2304
  role: 'user',
2195
2305
  source: {
2196
- kind: 'plugin',
2197
- plugin: name,
2306
+ kind: PLUGIN_SOURCE_KIND,
2198
2307
  form: 'snapshot',
2199
2308
  sections: [
2200
2309
  { name: '划词解读 · 选中文字', text },
@@ -3004,6 +3113,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
3004
3113
  () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }),
3005
3114
  `${name}: history route`,
3006
3115
  )
3116
+ ctx.effect(
3117
+ () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }),
3118
+ `${name}: pill position route`,
3119
+ )
3007
3120
  ctx.effect(
3008
3121
  () => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }),
3009
3122
  `${name}: models route`,