dsh-router-core 0.4.8 → 0.4.9

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.
@@ -218,6 +218,8 @@ export interface RouterExtResponse {
218
218
  /** 供应商模型(含启用状态)。 */
219
219
  export interface RouterSupplierModel {
220
220
  id: string;
221
+ /** 可读名(上游写的,只用于显示;缺省 = id)。见 `ModelInfo.name`。 */
222
+ name?: string;
221
223
  context_length?: number;
222
224
  enabled: boolean;
223
225
  /** 是否为自定义(手动添加 / 拉取)模型,可删除。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -10,6 +10,7 @@ import {
10
10
  type RouterComboAlias,
11
11
  type RouterComboSupplierGroup,
12
12
  } from '../shared.ts'
13
+ import { modelLabel, modelMatches } from './model-label.ts'
13
14
  import { Modal } from './Modal.tsx'
14
15
 
15
16
  /* ---------------- SVG 图标 ---------------- */
@@ -67,6 +68,22 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
67
68
  ?? groups.find(g => g.models.some(m => m.id === stored))
68
69
  return hit === undefined ? stored : `${hit.supplier.alias}/${stored}`
69
70
  }
71
+ /**
72
+ * 存储值 → 悬停说明。组合里存的、请求要发的都是 `alias/modelId`(上面那行),
73
+ * 但上游 key 往往不是人能读的(`qmodel_38max`),所以把上游的可读名
74
+ * (`Qwen3.8-Max`)放进 title 里,不占行宽也不改变「发什么」。
75
+ */
76
+ const comboTip = (stored: string): string => {
77
+ const name = displayName(stored)
78
+ const comma = stored.indexOf(',')
79
+ const mid = comma > 0 ? stored.slice(comma + 1) : stored
80
+ const sid = comma > 0 ? stored.slice(0, comma) : ''
81
+ const hit = (sid === '' ? groups : groups.filter(g => g.supplier.id === sid))
82
+ .flatMap(g => g.models)
83
+ .find(m => m.id === mid)
84
+ const readable = (hit?.name ?? '').trim()
85
+ return readable === '' || readable === mid ? name : `${readable}\n${name}(请求里用这个)`
86
+ }
70
87
  const [showForm, setShowForm] = useState(false)
71
88
  const [editing, setEditing] = useState<RouterCombo | null>(null)
72
89
  const [form, setForm] = useState<ComboForm>({ name: '', models: [] })
@@ -289,7 +306,7 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
289
306
  : (
290
307
  <>
291
308
  {combo.models.slice(0, 4).map((model, i) => (
292
- <span key={`${model}-${i}`} className="dshr-comboChip">
309
+ <span key={`${model}-${i}`} className="dshr-comboChip" title={comboTip(model)}>
293
310
  <span className="dshr-comboChipNum">{i + 1}</span>
294
311
  <span className="dshr-comboChipSupplier">{displayName(model)}</span>
295
312
  </span>
@@ -349,7 +366,7 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
349
366
  >
350
367
  <span className="dshr-comboGrip" title="拖拽排序"><Icon d={I.grip} size={14} /></span>
351
368
  <span className="dshr-comboStepNum">{index + 1}</span>
352
- <code className="dshr-comboSelectedModel">{displayName(model)}</code>
369
+ <code className="dshr-comboSelectedModel" title={comboTip(model)}>{displayName(model)}</code>
353
370
  <button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-comboStepRemove" title="移除" onClick={() => removeModel(index)}>
354
371
  <Icon d={I.delete} size={14} />
355
372
  </button>
@@ -379,16 +396,20 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
379
396
  : groups.map(g => {
380
397
  const items = g.models.filter(m =>
381
398
  !form.models.includes(`${g.supplier.id},${m.id}`) &&
382
- (modelQuery === '' || m.id.toLowerCase().includes(modelQuery.toLowerCase())))
399
+ (modelQuery === '' || modelMatches(modelQuery, m)))
383
400
  if (items.length === 0) return null
384
401
  return (
385
402
  <div key={g.supplier.id} className="dshr-comboPickGroup">
386
403
  <p className="dshr-comboPickGroupTitle">{g.supplier.name} · {g.supplier.alias}</p>
387
- {items.map(m => (
388
- <button key={m.id} type="button" className="dshr-comboPickItem" onClick={() => toggleModel(g.supplier.id, m.id)}>
389
- <span className="dshr-comboPickName">{m.id}</span>
390
- </button>
391
- ))}
404
+ {items.map(m => {
405
+ const lab = modelLabel(m)
406
+ return (
407
+ <button key={m.id} type="button" className="dshr-comboPickItem" title={lab.title} onClick={() => toggleModel(g.supplier.id, m.id)}>
408
+ <span className="dshr-comboPickName">{lab.label}</span>
409
+ {lab.showId && <span className="dshr-comboPickKey">{m.id}</span>}
410
+ </button>
411
+ )
412
+ })}
392
413
  </div>
393
414
  )
394
415
  })}
@@ -13,6 +13,7 @@ import {
13
13
  type RouterSupplierModelsResponse,
14
14
  } from '../shared.ts'
15
15
  import { statusBadge } from './account-badge.ts'
16
+ import { modelLabel, modelMatches } from './model-label.ts'
16
17
  import { Modal } from './Modal.tsx'
17
18
 
18
19
  /* ---------------- SVG 图标 ---------------- */
@@ -550,7 +551,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
550
551
  return out
551
552
  })()
552
553
  const q = modelQuery.trim().toLowerCase()
553
- const matchQuery = (m: RouterSupplierModel): boolean => q === '' || m.id.toLowerCase().includes(q)
554
+ const matchQuery = (m: RouterSupplierModel): boolean => modelMatches(q, m)
554
555
  const enabledModels = models?.filter(m => m.enabled && matchQuery(m)) ?? []
555
556
  const disabledModels = models?.filter(m => !m.enabled && matchQuery(m)) ?? []
556
557
 
@@ -763,19 +764,24 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
763
764
  {enabledModels.map(model => {
764
765
  const status = testResults[model.id]
765
766
  const chipCls = status === 'ok' ? ' dshr-modelChip-ok' : status === 'error' ? ' dshr-modelChip-err' : ''
767
+ const lab = modelLabel(model)
766
768
  return (
767
769
  <div key={model.id} className={`dshr-modelChipWrap${chipCls}`}>
768
770
  <button
769
771
  type="button"
770
772
  className="dshr-modelChip"
771
- title="点击禁用"
773
+ title={`${lab.title}\n点击禁用`}
772
774
  disabled={toggling}
773
775
  onClick={() => void toggleModel(model.id, false)}
774
776
  >
775
777
  <span className="dshr-modelChipStatus">
776
778
  <Icon d={status === 'ok' ? I.checkCircle : status === 'error' ? I.cancel : I.robot} size={13} />
777
779
  </span>
778
- <span className="dshr-modelChipId">{model.id}</span>
780
+ <span className="dshr-modelChipName">{lab.label}</span>
781
+ {lab.showId && <span className="dshr-modelChipKey">{model.id}</span>}
782
+ {typeof model.context_length === 'number' && model.context_length > 0 && (
783
+ <span className="dshr-modelChipCtx">{model.context_length}K</span>
784
+ )}
779
785
  </button>
780
786
  <div className="dshr-modelChipOps">
781
787
  <button
@@ -819,11 +825,11 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
819
825
  key={model.id}
820
826
  type="button"
821
827
  className="dshr-modelChip dshr-modelChip-off"
822
- title="点击恢复"
828
+ title={`${modelLabel(model).title}\n点击恢复`}
823
829
  disabled={toggling}
824
830
  onClick={() => void toggleModel(model.id, true)}
825
831
  >
826
- <span className="dshr-modelChipId">{model.id}</span>
832
+ <span className="dshr-modelChipName">{modelLabel(model).label}</span>
827
833
  <span className="dshr-modelChipRestore">+ 恢复</span>
828
834
  </button>
829
835
  ))}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 模型标签/搜索判据测试。
3
+ *
4
+ * 为什么要有:这两条判据错了都不会报错,只会让人在面板上做错事 ——
5
+ * 标签退化成 key(用户还是看不懂),或搜索只认 key(记不清 key 的人搜不到)。
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { modelLabel, modelMatches } from './model-label.ts'
10
+
11
+ test('有可读名:名字在前,key 作为副标签', () => {
12
+ const l = modelLabel({ id: 'qmodel_38max', name: 'Qwen3.8-Max' })
13
+ assert.equal(l.label, 'Qwen3.8-Max')
14
+ assert.equal(l.showId, true)
15
+ assert.match(l.title, /qmodel_38max/)
16
+ })
17
+
18
+ test('没有可读名 / 名字与 key 相同:只显示 key,不重复', () => {
19
+ assert.deepEqual(
20
+ { ...modelLabel({ id: 'gpt-4o' }), title: undefined },
21
+ { label: 'gpt-4o', showId: false, title: undefined },
22
+ )
23
+ assert.equal(modelLabel({ id: 'gpt-4o', name: 'gpt-4o' }).showId, false)
24
+ assert.equal(modelLabel({ id: 'gpt-4o', name: ' ' }).label, 'gpt-4o')
25
+ })
26
+
27
+ test('搜索:可读名与 key 都能命中', () => {
28
+ const m = { id: 'kmodel_latest', name: 'Kimi-K3.1-Pro' }
29
+ assert.equal(modelMatches('kimi', m), true)
30
+ assert.equal(modelMatches('KMODEL', m), true)
31
+ assert.equal(modelMatches(' ', m), true)
32
+ assert.equal(modelMatches('glm', m), false)
33
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 模型 chip 的标签 —— 「可读名 + 上游 key」两段。
3
+ *
4
+ * 为什么需要:有些上游的模型 key 是内部标识(Qoder 的 `qmodel_38max`、
5
+ * `kmodel_latest`、`mmodel`),面板上只剩一串猜不出含义的字母;而上游目录里
6
+ * 其实同时下发了可读名(`Qwen3.8-Max`、`Kimi-K3.1-Pro`)。丢掉它,用户点
7
+ * 「获取模型」之后仍然不知道自己勾的是什么。
8
+ *
9
+ * 但两段不能互相替代:**能发给上游的只有 key**。所以显示上可读名在前、key 在后
10
+ * (两者相同时只显示一次),复制按钮复制 key,请求/组合存的也是 key。
11
+ */
12
+
13
+ /** 一个模型在面板上的呈现。 */
14
+ export interface ModelLabel {
15
+ /** 主标签:上游给的可读名,没有(或与 key 相同)就用 key。 */
16
+ label: string
17
+ /** 是否还要把 key 作为副标签显示 —— 与主标签相同就不重复。 */
18
+ showId: boolean
19
+ /** 悬停提示:两个都给出来,并点明请求要用哪个。 */
20
+ title: string
21
+ }
22
+
23
+ /**
24
+ * 算出模型的展示标签。
25
+ * @param m 至少要有 `id`;`name` 是可读名(可缺、可空串)
26
+ */
27
+ export function modelLabel(m: { id: string; name?: string }): ModelLabel {
28
+ const name = (m.name ?? '').trim()
29
+ const label = name === '' || name === m.id ? m.id : name
30
+ const showId = label !== m.id
31
+ return {
32
+ label,
33
+ showId,
34
+ title: showId ? `${label}\n${m.id}(请求与组合里用这个)` : m.id,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 搜索匹配:可读名与 key **都**参与。
40
+ *
41
+ * 两种说法都要能搜到:记得模型叫什么的人搜 `kimi`,记得上游 key 的人搜
42
+ * `kmodel`;只匹配一个等于把另一半人的搜索框作废。
43
+ */
44
+ export function modelMatches(query: string, m: { id: string; name?: string }): boolean {
45
+ const q = query.trim().toLowerCase()
46
+ if (q === '') return true
47
+ return m.id.toLowerCase().includes(q) || (m.name ?? '').toLowerCase().includes(q)
48
+ }
@@ -1900,9 +1900,16 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
1900
1900
  cursor: default;
1901
1901
  }
1902
1902
 
1903
- .dshr-modelChipId {
1903
+ /* 主标签 = 上游的可读名(缺省回落成 key),正文字号;key 用等宽淡字跟在后面,
1904
+ 两者相同时不渲染 key(判据见 model-label.ts) */
1905
+ .dshr-modelChipName {
1906
+ font-size: 12px;
1907
+ }
1908
+
1909
+ .dshr-modelChipKey {
1904
1910
  font-family: var(--rs-mono);
1905
- font-size: 11px;
1911
+ font-size: 10px;
1912
+ color: var(--rs-faint);
1906
1913
  }
1907
1914
 
1908
1915
  .dshr-modelChipCtx {
@@ -2047,61 +2054,6 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2047
2054
  opacity: 1;
2048
2055
  }
2049
2056
 
2050
- /* 旧 .dshr-modelChip 独立样式(disabled 区仍用) */
2051
- .dshr-modelChip {
2052
- display: inline-flex;
2053
- align-items: center;
2054
- gap: 6px;
2055
- padding: 6px 10px;
2056
- border: 1px solid var(--rs-line);
2057
- border-radius: 8px;
2058
- color: var(--rs-text);
2059
- background: var(--rs-layer-1);
2060
- font-size: 12px;
2061
- cursor: pointer;
2062
- transition:
2063
- border-color 0.12s ease,
2064
- background 0.12s ease,
2065
- color 0.12s ease;
2066
- }
2067
-
2068
- .dshr-modelChip:hover {
2069
- border-color: var(--rs-accent);
2070
- background: color-mix(in srgb, var(--rs-accent) 6%, transparent);
2071
- }
2072
-
2073
- .dshr-modelChip:disabled {
2074
- opacity: 0.6;
2075
- cursor: default;
2076
- }
2077
-
2078
- .dshr-modelChipId {
2079
- font-family: var(--rs-mono);
2080
- font-size: 11px;
2081
- }
2082
-
2083
- .dshr-modelChipCtx {
2084
- flex: none;
2085
- color: var(--rs-faint);
2086
- font-size: 10px;
2087
- }
2088
-
2089
- .dshr-modelChip-off {
2090
- border-style: dashed;
2091
- color: var(--rs-faint);
2092
- opacity: 0.8;
2093
- }
2094
-
2095
- .dshr-modelChip-off:hover {
2096
- color: var(--rs-accent);
2097
- border-color: var(--rs-accent);
2098
- }
2099
-
2100
- .dshr-modelChipRestore {
2101
- color: var(--rs-accent);
2102
- font-size: 10px;
2103
- }
2104
-
2105
2057
  /* 计数徽标跟在标题/操作按钮右侧(模型卡片) */
2106
2058
  .dshr-cardMetaAfter {
2107
2059
  margin-left: 0;
@@ -2484,9 +2436,20 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2484
2436
  }
2485
2437
 
2486
2438
  .dshr-comboPickName {
2487
- flex: 1;
2439
+ flex: 0 1 auto;
2488
2440
  min-width: 0;
2441
+ overflow: hidden;
2442
+ text-overflow: ellipsis;
2443
+ white-space: nowrap;
2444
+ }
2445
+
2446
+ /* 挑选项里的上游 key:可读名之后的小号等宽淡字,两者相同时不渲染 */
2447
+ .dshr-comboPickKey {
2448
+ flex: none;
2449
+ margin-left: auto;
2489
2450
  font-family: var(--rs-mono);
2451
+ font-size: 10px;
2452
+ color: var(--rs-faint);
2490
2453
  overflow: hidden;
2491
2454
  text-overflow: ellipsis;
2492
2455
  white-space: nowrap;
@@ -752,3 +752,19 @@ test('正常供应商(listModels 全量返回):列表内容与启用状态
752
752
  assert.deepEqual(models.map((m) => m.id).sort(), ['a-1', 'a-2'])
753
753
  assert.deepEqual(models.map((m) => m.enabled).sort(), [false, true], '启用状态仍由核心合并')
754
754
  })
755
+
756
+ test('可读名原样透传到面板,且 id 不被它替换', async () => {
757
+ const router = new Router('')
758
+ // Qoder 的形状:id 是上游内部 key,可读名在 name 里
759
+ const a = supplier('a', [{ id: 'qmodel_38max', name: 'Qwen3.8-Max', context_length: 200 }])
760
+ addSupplier(router, a.s)
761
+
762
+ const models = await router.modelsOf('a')
763
+ assert.deepEqual(models, [{
764
+ id: 'qmodel_38max',
765
+ name: 'Qwen3.8-Max',
766
+ context_length: 200,
767
+ enabled: true,
768
+ custom: undefined,
769
+ }], '两个字段各自都必须在:拿 name 去请求上游会 404,只剩 id 则面板上看不懂')
770
+ })
@@ -35,7 +35,18 @@ export interface SupplierStatus {
35
35
 
36
36
  /** OpenAI 模型条目。 */
37
37
  export interface ModelInfo {
38
+ /** 上游的模型 key(**唯一标识**:组合存它、路由认它、上游收它)。 */
38
39
  id: string
40
+ /**
41
+ * 可读名(上游自己写的,如 `Qwen3.8-Max`)。**只用于显示**,缺省 = `id`。
42
+ *
43
+ * 为什么需要它:有些上游的 key 是内部标识(Qoder 的 `qmodel_38max`、
44
+ * `kmodel_latest`),面板上只剩一串猜不出含义的字母。既然上游在目录里
45
+ * 同时下发了 `display_name`,丢掉它就是把可读信息换成噪声。
46
+ * 反过来,**凡是"发给上游/存下来"的地方都得继续用 `id`**——用名字去请求
47
+ * 上游只会拿到 404。
48
+ */
49
+ name?: string
39
50
  context_length?: number
40
51
  }
41
52
 
package/src/shared.ts CHANGED
@@ -227,6 +227,8 @@ export interface RouterExtResponse {
227
227
  /** 供应商模型(含启用状态)。 */
228
228
  export interface RouterSupplierModel {
229
229
  id: string
230
+ /** 可读名(上游写的,只用于显示;缺省 = id)。见 `ModelInfo.name`。 */
231
+ name?: string
230
232
  context_length?: number
231
233
  enabled: boolean
232
234
  /** 是否为自定义(手动添加 / 拉取)模型,可删除。 */