dsh-router-core 0.3.21 → 0.4.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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 面板供应商可见性判据测试 —— 锁住三件事:
3
+ * 1. 关掉的卡片不出现(与扩展页同一形状)
4
+ * 2. 缺 `enabled` 按开着读(老核心没这个字段,不能让它们集体消失)
5
+ * 3. 空状态分「没装」与「全关掉」两种(装了却说暂无 = 说假话)
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { supplierVisibility, type SupplierSummary } from './supplier-visibility.ts'
10
+
11
+ const s = (id: string, source: SupplierSummary['source'], enabled?: boolean): SupplierSummary => ({
12
+ id,
13
+ name: id,
14
+ ...(source === undefined ? {} : { source }),
15
+ ...(enabled === undefined ? {} : { enabled }),
16
+ })
17
+
18
+ test('关掉的供应商不出现在面板(卡片不出现)', () => {
19
+ const v = supplierVisibility([
20
+ s('codebuddy', 'external', true),
21
+ s('traework', 'external', false),
22
+ s('opencode', 'builtin', false),
23
+ ])
24
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
25
+ assert.deepEqual(v.builtin, [])
26
+ assert.equal(v.hidden, 2)
27
+ })
28
+
29
+ test('缺 enabled 按开着读 —— 老核心没有这个字段,不能让供应商集体消失', () => {
30
+ const v = supplierVisibility([s('codebuddy', 'external'), s('opencode', 'builtin')])
31
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
32
+ assert.deepEqual(v.builtin.map((x) => x.id), ['opencode'])
33
+ assert.equal(v.hidden, 0)
34
+ assert.equal(v.empty, undefined, '有卡片可列时不该有空状态')
35
+ })
36
+
37
+ test('按来源分组不串(内置 / 插件两组互不吞)', () => {
38
+ const v = supplierVisibility([
39
+ s('opencode', 'builtin', true),
40
+ s('codebuddy', 'external', true),
41
+ s('nvidia', 'builtin', true),
42
+ ])
43
+ assert.deepEqual(v.builtin.map((x) => x.id), ['opencode', 'nvidia'])
44
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
45
+ })
46
+
47
+ test('全关掉 ≠ 暂无:空状态必须说清去哪开回来', () => {
48
+ const v = supplierVisibility([s('codebuddy', 'external', false)])
49
+ assert.equal(v.empty?.reason, 'disabled')
50
+ assert.match(v.empty?.desc ?? '', /设置 → 插件 → dsh-router-core/,
51
+ '空状态要把用户指到开关所在处,否则用户以为插件坏了')
52
+ })
53
+
54
+ test('一个都没装才是「暂无供应商」', () => {
55
+ assert.equal(supplierVisibility([]).empty?.reason, 'none')
56
+ assert.equal(supplierVisibility([s('x', 'external', false), s('y', 'builtin', false)]).empty?.reason, 'disabled')
57
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 面板「供应商」页的可见性规则 —— 纯函数,不碰 React。
3
+ *
4
+ * 形状与插件页**刻意相反**:
5
+ * - 面板(设置 → 路由 → 供应商)= **在用什么**的工作面,只列**开着**的;
6
+ * 关掉的卡片不出现(与「扩展」页同一形状)。
7
+ * - 插件页(设置 → 插件 → dsh-router-core → 路由组件)= **控制面**,供应商组
8
+ * 列全部(含关掉的),否则关掉之后就再没有可点的开关能开回来。
9
+ *
10
+ * 为什么空状态要分两种:「暂无供应商」说的是**一个都没装**,而「都关掉了」是
11
+ * 装了但用户自己关的。两者共用一句「暂无供应商」就是说假话 —— 用户进面板发现
12
+ * 自己装的东西不见了,而真正的原因(开关在另一个页面关着)无处可寻。
13
+ *
14
+ * 抽出来是为了让「哪些卡片该出现」这条判据能被单测钉住:它一行就能写错,
15
+ * 而写错的表现是「面板说关了、请求照样打过去」或者「用户以为没装」。
16
+ */
17
+ import type { RouterHealthResponse } from '../shared.ts'
18
+
19
+ /** `/health` 里的一条供应商摘要(就地取型,不另抄一份形状)。 */
20
+ export type SupplierSummary = NonNullable<RouterHealthResponse['suppliers']>[number]
21
+
22
+ /** 面板要渲染的分组。 */
23
+ export interface SupplierVisibility {
24
+ /** 随核心分发、且开着的。 */
25
+ builtin: SupplierSummary[]
26
+ /** 独立安装、且开着的。 */
27
+ external: SupplierSummary[]
28
+ /** 装过但被开关关掉的个数(不列卡片,但空状态要用它说明去哪开回来)。 */
29
+ hidden: number
30
+ /**
31
+ * 空状态文案。`undefined` = 不该显示空状态(有卡片可列)。
32
+ * `reason: 'none'` 是一个都没装;`reason: 'disabled'` 是装了但全被关掉。
33
+ */
34
+ empty?: { reason: 'none' | 'disabled'; title: string; desc: string }
35
+ }
36
+
37
+ /** 开关在插件页的固定位置 —— 空状态要把用户指过去,不然就断在这里了。 */
38
+ const SWITCH_HOME = '设置 → 插件 → dsh-router-core'
39
+
40
+ /**
41
+ * 按「开着与否 + 来源」把供应商分成面板要渲染的两组,并给出空状态该说什么。
42
+ *
43
+ * `enabled` 缺省按**开着**读(老核心没有这个字段);只有明确的 `false` 才算关。
44
+ */
45
+ export function supplierVisibility(suppliers: readonly SupplierSummary[]): SupplierVisibility {
46
+ const on = suppliers.filter((s) => s.enabled !== false)
47
+ const builtin = on.filter((s) => s.source !== 'external')
48
+ const external = on.filter((s) => s.source === 'external')
49
+ const hidden = suppliers.length - on.length
50
+ if (on.length > 0) return { builtin, external, hidden }
51
+ return {
52
+ builtin,
53
+ external,
54
+ hidden,
55
+ empty: hidden > 0
56
+ ? {
57
+ reason: 'disabled',
58
+ title: '供应商都已关闭',
59
+ desc: `开关在 ${SWITCH_HOME} 的「路由组件」一节;打开后会重新参与路由`,
60
+ }
61
+ : { reason: 'none', title: '暂无供应商', desc: '' },
62
+ }
63
+ }
@@ -25,7 +25,6 @@
25
25
  * file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis'
28
- import type { IncomingMessage, ServerResponse } from 'node:http'
29
28
 
30
29
  /** 扩展器唯一 id(注册键)。 */
31
30
  export type ExtId = string
@@ -56,14 +55,17 @@ export interface RouterExt {
56
55
  readonly description?: string
57
56
  /** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
58
57
  readonly icon?: string
59
- /** 当前运行时状态(ready + 不就绪时的说明)。 */
60
- getState(): ExtState
61
58
  /**
62
- * 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
63
- * 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
64
- * false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
59
+ * 这个扩展是**随核心分发**(`builtin`)还是**独立安装的插件**(不标 = plugin)。
60
+ *
61
+ * 为什么需要这个标记:随核心分发的扩展同时是插件页原生「包含的组件」里的**一行**
62
+ * (自带宿主管的开关),独立安装的扩展是各自 bundle 的行。插件页那个自绘的
63
+ * 「路由组件」节只该列后者 —— 与内置供应商的处理完全一样:已经有原生行的东西,
64
+ * 再在下面列一遍就是同一个东西显示两处。
65
65
  */
66
- api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>
66
+ readonly source?: 'builtin'
67
+ /** 当前运行时状态(ready + 不就绪时的说明)。 */
68
+ getState(): ExtState
67
69
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
68
70
  dispose?(): void
69
71
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 插件自检扩展(`router.ext` 侧)测试 —— 锁住三件事:
3
+ * 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
4
+ * 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
5
+ * 3. 恒为就绪 —— 自检能力不依赖外部二进制,不该提前否决(真跑一次才知道)。
6
+ *
7
+ * 为什么测这个:扩展的「卡片出现与否」完全取决于它在不在这张表里,而表是核心
8
+ * provide 的**同一个 live 对象**。登记没发生的话,面板与插件页都看不到它,且不报错。
9
+ */
10
+ import test from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { createTestExt, EXT_TEST_ID } from './plugin.ts'
13
+ import { apply, name as CORDIS_NAME } from './index.ts'
14
+ import type { RouterExtService } from '../ext/contract.ts'
15
+
16
+ test('扩展器带上面板要用的身份,且恒为就绪', () => {
17
+ const ext = createTestExt()
18
+ assert.equal(ext.id, EXT_TEST_ID)
19
+ assert.equal(ext.name, '插件自检')
20
+ assert.ok((ext.description ?? '').length > 0, '没有说明,插件页那一行就没有副标题')
21
+ assert.equal(ext.getState().ready, true, '自检不依赖本机二进制,不该报未就绪')
22
+ })
23
+
24
+ test('cordis 身份是行 id(与 patch 里声明的逐字相同,否则 loader 认不出)', () => {
25
+ assert.equal(CORDIS_NAME, 'dsh-router-ext-test')
26
+ })
27
+
28
+ /** 最小 ctx:inject 立刻回调、emit 记下来。 */
29
+ function fakeCtx(table: RouterExtService): {
30
+ ctx: Parameters<typeof apply>[0]
31
+ events: string[]
32
+ } {
33
+ const events: string[] = []
34
+ return {
35
+ events,
36
+ ctx: {
37
+ inject: (deps: string[], cb: (sctx: unknown) => void) => {
38
+ cb({ get: (key: string) => (key === 'router.ext' ? table : undefined) })
39
+ },
40
+ emit: (event: string) => { events.push(event) },
41
+ } as unknown as Parameters<typeof apply>[0],
42
+ }
43
+ }
44
+
45
+ test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', () => {
46
+ const table: RouterExtService = {}
47
+ const { ctx, events } = fakeCtx(table)
48
+ apply(ctx)
49
+ assert.equal(table[EXT_TEST_ID]?.name, '插件自检')
50
+ assert.deepEqual(events, [], '核心持有同一个 live 对象且每次请求现读,不需要广播事件')
51
+ })
52
+
53
+ test('幂等:同一个 id 已在表里时不顶掉别人的登记', () => {
54
+ const table: RouterExtService = {}
55
+ const { ctx } = fakeCtx(table)
56
+ apply(ctx)
57
+ const first = table[EXT_TEST_ID]
58
+ apply(ctx)
59
+ assert.equal(table[EXT_TEST_ID], first, '重复登记不该换成一个新对象')
60
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 插件自检扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
3
+ * 原生「包含的组件」里的那一行。
4
+ *
5
+ * 壳存在的理由与内置供应商那三层一样:`plugin.ts` 已经把 `name` 用作**面板显示名**
6
+ * (扩展契约的字段,所有扩展都这么写),而 cordis 的插件身份也要一个 `name`,两者抢
7
+ * 同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
8
+ *
9
+ * **默认关闭**(patch 里 `disabled: true`):行关闭时 loader 根本不 import 这个模块,
10
+ * 扩展也就没进 `router.ext` 表 —— 所以「打开才出现这张卡片」是天然的,不需要额外的
11
+ * 状态位。用户在这一行打开后,卡片出现在插件页的「路由组件 → 扩展」组与面板的
12
+ * 「扩展」页。
13
+ *
14
+ * 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
15
+ * (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import { currentExts, currentExtStore } from '../ext/contract.ts'
19
+ import { createTestExt, EXT_TEST_ID } from './plugin.ts'
20
+
21
+ /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
22
+ export const name = 'dsh-router-ext-test'
23
+
24
+ /** 登记本扩展。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
25
+ export function apply(ctx: Context): void {
26
+ ctx.inject(['router.ext'], (sctx) => {
27
+ const table = currentExts(sctx)
28
+ if (table === undefined) return
29
+ if (table[EXT_TEST_ID] !== undefined) return
30
+ table[EXT_TEST_ID] = createTestExt()
31
+ currentExtStore(sctx)?.setEnabled(EXT_TEST_ID, true)
32
+ // 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
33
+ // 现读这张表 —— 所以往里 append 就够了,不需要(也不存在)广播事件。
34
+ })
35
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Plugin self-check",
4
+ "description": "Run a real access test against a supplier/model/connection, or run a contract check over an external supplier plugin (every member: implemented / usable / not executed)"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "插件自检",
4
+ "description": "选供应商、模型与连接跑一次真实访问测试;或对外部供应商插件做契约体检,逐个成员报告实现与可用情况"
5
+ }
6
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 插件自检扩展(`router.ext` 侧的实现)—— 声明与状态都在这里,**不做任何拦截**。
3
+ *
4
+ * 它和 rtk 那类扩展的区别要写清楚:本扩展**不挂任何监听、不改写任何命令**,
5
+ * 唯一作用是让用户在官方插件页里打开一块自检面板(选供应商 / 模型 / 连接,跑一次
6
+ * 真实的访问测试并看到底报了什么)。所以它复用 `router.ext` 这条通道只是为了拿到
7
+ * **发现 + 开关**两件事,而不是为了拦截。
8
+ *
9
+ * 面板本身在浏览器侧(`src/client/ExtTestPanel.tsx`),通过原生行的
10
+ * `plugins.row.config` 座位挂在「包含的组件」里那一行的详情页上;这里只负责让它在
11
+ * 列表里有个身份、以及报出运行时就绪状态。
12
+ */
13
+ import type { RouterExt } from '../ext/contract.ts'
14
+
15
+ /** 扩展开关表里的注册键(也是 URL 面板读它的 id)。 */
16
+ export const EXT_TEST_ID = 'test'
17
+
18
+ /**
19
+ * 构造这个扩展器。
20
+ *
21
+ * `getState()` 恒为就绪:自检能力不依赖外部二进制(不像 rtk 需要本机装 rtk),
22
+ * 「能不能测」取决于用户选的供应商/模型/连接,不该由扩展提前否决 —— 真跑一次
23
+ * 才知道,那时错误信息比一个「未就绪」有用得多。
24
+ */
25
+ export function createTestExt(): RouterExt {
26
+ return {
27
+ id: EXT_TEST_ID,
28
+ name: '插件自检',
29
+ description: '选供应商、模型与连接跑一次真实访问测试;或对外部供应商插件做契约体检,逐个成员报告实现与可用情况',
30
+ // 随核心分发 -> 插件页的原生「包含的组件」里有它一行,自绘节里不再重复列。
31
+ source: 'builtin' as const,
32
+ getState: () => ({ ready: true }),
33
+ }
34
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-router-core-ext-test",
3
+ "type": "module",
4
+ "description": "插件自检扩展(随 dsh-router 核心分发)"
5
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 宿主版本探测测试 —— 锁死「最近命中徽章仅在 >= 0.1.7 出现」这条判定。
3
+ *
4
+ * 起因:`conversation.composer.dock` 座位在 0.1.5 也在,但渲染在 InputBar 之后
5
+ * 的独立块(位置不对);只有 0.1.7 才放进底部操作行。座位名相同没法靠契约区分,
6
+ * 只能按宿主版本号判定——本测试就是这条判据的唯一守卫。
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
15
+
16
+ /** 造一个「装了指定版本 dsh」的临时 profile,返回它的 file: URL。 */
17
+ function profileWithDsh(version: string | null): string {
18
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-hostver-'))
19
+ if (version !== null) {
20
+ const pkgDir = join(dir, 'node_modules', '@deepseek-ai', 'dsh')
21
+ mkdirSync(pkgDir, { recursive: true })
22
+ writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: 'dsh', version }))
23
+ }
24
+ return pathToFileURL(dir).href + '/'
25
+ }
26
+
27
+ test('读得到版本:detectHostVersion 返回 profile 里装的 dsh 版本', () => {
28
+ assert.equal(detectHostVersion(profileWithDsh('0.1.5-rc.3')), '0.1.5-rc.3')
29
+ assert.equal(detectHostVersion(profileWithDsh('0.1.7-alpha.2')), '0.1.7-alpha.2')
30
+ })
31
+
32
+ test('支持位:>= 0.1.7 才为真(预发布标识忽略,按前导数字比)', () => {
33
+ assert.equal(supportsLastHitDock(profileWithDsh('0.1.7-alpha.2')), true)
34
+ assert.equal(supportsLastHitDock(profileWithDsh('0.1.7')), true)
35
+ assert.equal(supportsLastHitDock(profileWithDsh('0.2.0')), true)
36
+ assert.equal(supportsLastHitDock(profileWithDsh('1.0.0')), true)
37
+ // 低于 0.1.7 → 不挂(0.1.5 位置不对,宁可不显示)
38
+ assert.equal(supportsLastHitDock(profileWithDsh('0.1.5-rc.3')), false)
39
+ assert.equal(supportsLastHitDock(profileWithDsh('0.1.6-alpha.2')), false)
40
+ assert.equal(supportsLastHitDock(profileWithDsh('0.1.4')), false)
41
+ })
42
+
43
+ test('isHost017Plus 与徽章支持位同源(三处兼容分叉共用一个判据)', () => {
44
+ for (const v of ['0.1.7-alpha.2', '0.1.7', '0.2.0', '1.0.0']) {
45
+ assert.equal(isHost017Plus(profileWithDsh(v)), true, v)
46
+ assert.equal(supportsLastHitDock(profileWithDsh(v)), true, v)
47
+ }
48
+ for (const v of ['0.1.5-rc.3', '0.1.6-alpha.2', '0.1.4']) {
49
+ assert.equal(isHost017Plus(profileWithDsh(v)), false, v)
50
+ assert.equal(supportsLastHitDock(profileWithDsh(v)), false, v)
51
+ }
52
+ })
53
+
54
+ test('读不到版本 → 一律当不支持(不抛、宁可不显示)', () => {
55
+ assert.equal(detectHostVersion(profileWithDsh(null)), undefined)
56
+ assert.equal(supportsLastHitDock(profileWithDsh(null)), false)
57
+ // 垃圾版本号
58
+ assert.equal(supportsLastHitDock(profileWithDsh('not-a-version')), false)
59
+ // baseUrl 缺失(冷启动/单测)→ 回落 ~/.dsh/profiles/web,不抛
60
+ assert.doesNotThrow(() => supportsLastHitDock(undefined))
61
+ })
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
3
+ *
4
+ * 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
5
+ * ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
6
+ * 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
7
+ * profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
8
+ *
9
+ * 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
10
+ * 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
11
+ * Config 驱动(无此方法)→ 按版本走对应注册路径。
12
+ * 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
13
+ * 块;0.1.7+ = 独立 role:'tool' 消息。
14
+ *
15
+ * 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
16
+ * 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
17
+ * 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
18
+ * 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
19
+ *
20
+ * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
21
+ * 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
22
+ */
23
+ import { readFileSync } from 'node:fs'
24
+ import { createRequire } from 'node:module'
25
+ import { join } from 'node:path'
26
+ import { profileDirOf } from './data-dir.ts'
27
+
28
+ /** 各兼容分叉的版本门槛。 */
29
+ /** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
30
+ const V0_1_7: readonly number[] = [0, 1, 7]
31
+
32
+ /**
33
+ * 解析并比较 semver(只取前导数字段,预发布标识忽略)。
34
+ * `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
35
+ * 返回负数/0/正数,语义同 `<`/`==`/`>`。
36
+ */
37
+ function compareVersion(a: readonly number[], b: readonly number[]): number {
38
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
39
+ const x = a[i] ?? 0
40
+ const y = b[i] ?? 0
41
+ if (x !== y) return x - y
42
+ }
43
+ return 0
44
+ }
45
+
46
+ /** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
47
+ function parseVersion(raw: string): number[] | null {
48
+ const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim())
49
+ if (m === null) return null
50
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)]
51
+ }
52
+
53
+ /**
54
+ * 探测 profile 里安装的 DSH 宿主版本。
55
+ * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
56
+ * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
57
+ */
58
+ export function detectHostVersion(baseUrl?: string): string | undefined {
59
+ const profile = profileDirOf(baseUrl)
60
+ // 先走模块解析(能沿 node_modules 向上找到全局安装根),再回落直接拼路径。
61
+ const candidates: string[] = []
62
+ try {
63
+ candidates.push(createRequire(join(profile, 'noop.js')).resolve('@deepseek-ai/dsh/package.json'))
64
+ } catch {
65
+ // 解析不到就走下面的直接路径
66
+ }
67
+ candidates.push(join(profile, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
68
+ for (const file of candidates) {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
71
+ if (typeof parsed.version === 'string' && parsed.version !== '') return parsed.version
72
+ } catch {
73
+ // 换下一个候选
74
+ }
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ /** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
80
+ function atLeast(baseUrl: string | undefined, min: readonly number[]): boolean {
81
+ const v = detectHostVersion(baseUrl)
82
+ if (v === undefined) return false
83
+ const parsed = parseVersion(v)
84
+ if (parsed === null) return false
85
+ return compareVersion(parsed, min) >= 0
86
+ }
87
+
88
+ /**
89
+ * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
90
+ * - 「最近命中」徽章(composer.dock 位置正确)
91
+ * - 设置命名空间(Config 驱动而非 installSection)
92
+ * - adapter 的独立 role:'tool' 消息形态
93
+ */
94
+ export function isHost017Plus(baseUrl?: string): boolean {
95
+ return atLeast(baseUrl, V0_1_7)
96
+ }
97
+
98
+ /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
99
+ export function supportsLastHitDock(baseUrl?: string): boolean {
100
+ return isHost017Plus(baseUrl)
101
+ }