@sqdr2/dsh-fusion 0.3.3 → 0.3.4

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/lib/dsh-fusion.js CHANGED
@@ -608,16 +608,17 @@ function profileRoutes(profile) {
608
608
  //#endregion
609
609
  //#region src/host/settings.ts
610
610
  /** Configuration chooses registered physical models; it cannot create adapters. */
611
- function validatePairChoice(value, catalog) {
612
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(bi("请选择 Lead 和 Worker", "Choose a Lead and a Sidekick"));
611
+ function validatePairChoice(value, catalog, combo) {
612
+ const say = (zh, en) => combo ? bi(`组合「${combo}」:${zh}`, `Combo "${combo}": ${en}`) : bi(zh, en);
613
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(say("请选择 Lead 和 Worker", "Choose a Lead and a Sidekick"));
613
614
  const raw = value;
614
615
  const route = (role, candidate) => {
615
616
  const item = candidate;
616
- if (!item || typeof item.provider !== "string" || typeof item.model !== "string") throw new Error(bi(`请选择 ${role} 模型`, `Choose the ${role} model`));
617
- if (isFusionProvider(item.provider)) throw new Error(bi("Fusion 必须选择真实模型", "Fusion needs real models, not Fusion itself"));
617
+ if (!item || typeof item.provider !== "string" || typeof item.model !== "string") throw new Error(say(`请选择 ${role} 模型`, `Choose the ${role} model`));
618
+ if (isFusionProvider(item.provider)) throw new Error(say("Fusion 必须选择真实模型", "Fusion needs real models, not Fusion itself"));
618
619
  const model = catalog.groups.find((group) => group.id === item.provider)?.models.find((model) => model.id === item.model);
619
- if (!model) throw new Error(bi(`${role} 模型已不可用,请刷新后重选`, `The ${role} model is no longer available; refresh and choose again`));
620
- if (item.reasoningEffort !== void 0 && (typeof item.reasoningEffort !== "string" || !model.reasoning?.efforts.some((effort) => effort.id === item.reasoningEffort))) throw new Error(bi(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`));
620
+ if (!model) throw new Error(say(`${role} 模型已不可用:${item.provider}/${item.model}。请为该角色重新选择模型`, `The ${role} model is no longer available: ${item.provider}/${item.model}. Choose a model for that role again`));
621
+ if (item.reasoningEffort !== void 0 && (typeof item.reasoningEffort !== "string" || !model.reasoning?.efforts.some((effort) => effort.id === item.reasoningEffort))) throw new Error(say(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`));
621
622
  return {
622
623
  provider: item.provider,
623
624
  model: item.model,
@@ -629,7 +630,7 @@ function validatePairChoice(value, catalog) {
629
630
  let outputTokens;
630
631
  if (raw.outputTokens !== void 0) {
631
632
  const value = raw.outputTokens;
632
- if (!value || typeof value !== "object" || Array.isArray(value) || Object.entries(value).some(([role, tokens]) => !["lead", "worker"].includes(role) || !Number.isSafeInteger(tokens) || Number(tokens) < 1 || Number(tokens) > 128e3)) throw new Error(bi("每次响应上限必须是 1–128000 的整数 Token 数", "The per-response limit must be an integer of 1–128000 tokens"));
633
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.entries(value).some(([role, tokens]) => !["lead", "worker"].includes(role) || !Number.isSafeInteger(tokens) || Number(tokens) < 1 || Number(tokens) > 128e3)) throw new Error(say("每次响应上限必须是 1–128000 的整数 Token 数", "The per-response limit must be an integer of 1–128000 tokens"));
633
634
  outputTokens = { ...value };
634
635
  }
635
636
  return {
@@ -834,7 +835,7 @@ function validatePairs(value, catalog, existing) {
834
835
  return {
835
836
  id,
836
837
  name,
837
- pair: validatePairChoice(raw.pair, catalog)
838
+ pair: validatePairChoice(raw.pair, catalog, name)
838
839
  };
839
840
  });
840
841
  }
@@ -28,8 +28,23 @@ export declare function savedLeadRoute(value: unknown): {
28
28
  provider: string;
29
29
  model: string;
30
30
  } | undefined;
31
+ /** The roles a saved pair names, in the order the settings page shows them. */
32
+ export type PairRole = 'lead' | 'worker' | 'compactor';
33
+ /**
34
+ * The routes a saved choice still names that the current catalog no longer
35
+ * offers: a provider that was renamed or removed, or a model that was retired.
36
+ *
37
+ * Reading a saved combo tolerates a stale route so the row stays visible and
38
+ * editable, but saving one does not. Without this, the page can only say that
39
+ * *some* model is gone, which is unusable when the stale route is not the one
40
+ * being edited — the user then cannot tell which combo to fix.
41
+ */
42
+ export declare function missingRoutes(value: Partial<PairChoice> | undefined, catalog: SettingsCatalog): {
43
+ role: PairRole;
44
+ route: PhysicalRoute;
45
+ }[];
31
46
  /** Configuration chooses registered physical models; it cannot create adapters. */
32
- export declare function validatePairChoice(value: unknown, catalog: SettingsCatalog): PairChoice;
47
+ export declare function validatePairChoice(value: unknown, catalog: SettingsCatalog, combo?: string): PairChoice;
33
48
  /** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
34
49
  export declare const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128000;
35
50
  export declare function profileFromChoice(pair: PairChoice, id?: string): Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqdr2/dsh-fusion",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -3,6 +3,7 @@ import { FusionCache } from './FusionCache.js'
3
3
  import { useEffect, useState } from 'react'
4
4
  import type { PhysicalRoute } from '../contracts.js'
5
5
  import type { PairChoice, SettingsCatalog } from '../host/settings.js'
6
+ import { missingRoutes } from '../host/settings.js'
6
7
  import { settingsChanged } from './settings-events.js'
7
8
  import { tr, useLang } from './i18n.js'
8
9
  import { FUSION_API_PATH } from '../identity.js'
@@ -57,6 +58,7 @@ const styles = `
57
58
  .fusion-settings .fusion-combo-text{display:flex;flex-direction:column;gap:2px;min-width:0}
58
59
  .fusion-settings .fusion-combo-name{font-weight:600;overflow-wrap:anywhere}
59
60
  .fusion-settings .fusion-combo-routes{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}
61
+ .fusion-settings .fusion-combo-stale{color:var(--dsw-alias-state-error-primary);font-size:12px;overflow-wrap:anywhere}
60
62
  .fusion-settings .fusion-combo-actions{flex:none;align-self:stretch;display:flex;align-items:center;padding:0 14px;border:0;border-left:1px solid var(--dsw-alias-border-l1);border-radius:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;transition:color .15s ease,background-color .15s ease}
61
63
  .fusion-settings .fusion-combo-actions:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background-color:var(--dsw-alias-interactive-bg-hover-danger)}
62
64
  .fusion-settings .fusion-combo-actions:disabled{opacity:.4;cursor:default}
@@ -127,6 +129,10 @@ export function ModelChoice({ role, route, catalog, onChange }: {
127
129
  const lang = useLang()
128
130
  const name = role === 'compactor' ? tr(lang, '压缩', 'Compaction') : role
129
131
  const model = catalog.groups.find(group => group.id === route?.provider)?.models.find(model => model.id === route?.model)
132
+ // A saved route the catalog no longer offers would otherwise fall back to the
133
+ // placeholder, silently reading as "nothing chosen" — and hiding which model
134
+ // went away. Keep it listed, disabled and labelled, until it is replaced.
135
+ const stale = route !== undefined && model === undefined
130
136
  return <fieldset>
131
137
  <legend>{name}</legend>
132
138
  <p>{role === 'Lead' ? tr(lang, '回答问题、制定方案并审查结果。', 'Answers, plans and reviews.') : role === 'Sidekick' ? tr(lang, '执行命令和修改,接收反馈后继续。', 'Runs commands and makes changes, then continues with feedback.') : tr(lang, '整理较早的上下文,供 Lead 或 Worker 继续工作。', 'Summarises older context so the Lead or Sidekick can continue.')}</p>
@@ -136,10 +142,13 @@ export function ModelChoice({ role, route, catalog, onChange }: {
136
142
  onChange({ provider, model })
137
143
  }}>
138
144
  <option value="" disabled={role !== 'compactor'}>{role === 'compactor' ? tr(lang, '使用当前角色模型', 'Use the role\'s own model') : tr(lang, '选择已连接的模型', 'Choose a connected model')}</option>
145
+ {stale && route && <option value={key(route)} disabled>{tr(lang, `已不可用:${route.provider}/${route.model}`, `No longer available: ${route.provider}/${route.model}`)}</option>}
139
146
  {catalog.groups.map(group => <optgroup key={group.id} label={group.name}>
140
147
  {group.models.map(model => <option key={model.id} value={key({ provider: group.id, model: model.id })}>{model.name}</option>)}
141
148
  </optgroup>)}
142
149
  </select></label>
150
+ {stale && route && <p role="alert">{tr(lang, `这个角色保存的模型 ${route.provider}/${route.model} 已不可用,请重新选择。`,
151
+ `This role's saved model ${route.provider}/${route.model} is no longer available; choose another.`)}</p>}
143
152
  {model?.reasoning && route && <label>{tr(lang, '推理强度', 'Reasoning')}<select aria-label={tr(lang, `${name} 推理强度`, `${name} reasoning`)} value={route.reasoningEffort ?? ''} onChange={event => {
144
153
  const { reasoningEffort: _old, ...physical } = route
145
154
  onChange({ ...physical, ...(event.target.value ? { reasoningEffort: event.target.value } : {}) })
@@ -165,6 +174,8 @@ export function FusionSettings() {
165
174
  const routeName = (route?: PhysicalRoute) => route
166
175
  ? state?.catalog.groups.find(group => group.id === route.provider)?.models.find(model => model.id === route.model)?.name
167
176
  ?? `${route.provider}/${route.model}` : '—'
177
+ const roleLabel = (role: 'lead' | 'worker' | 'compactor') => role === 'lead' ? tr(lang, 'Lead', 'Lead')
178
+ : role === 'worker' ? tr(lang, 'Sidekick', 'Sidekick') : tr(lang, '压缩模型', 'Compaction model')
168
179
  const update = (change: (combo: DraftCombo) => DraftCombo) => setDraft(value => value.map((combo, index) =>
169
180
  combo.key === current?.key ? change(combo) : combo))
170
181
  const chooseModel = (role: 'lead' | 'worker', route: PhysicalRoute) => update(combo => ({ ...combo, pair: { ...combo.pair, [role]: route,
@@ -190,6 +201,13 @@ export function FusionSettings() {
190
201
  if (!state) return
191
202
  const invalid = draft.findIndex(combo => !combo.pair.lead || !combo.pair.worker || !combo.name.trim())
192
203
  if (invalid >= 0) { setError(tr(lang, `第 ${invalid + 1} 个组合还不完整:需要名称、Lead 和 Sidekick。`, `Combo ${invalid + 1} is incomplete: it needs a name, a Lead and a Sidekick.`)); return }
204
+ // A route that no longer exists cannot be saved, and the Host's own refusal
205
+ // names only the role. Checked here so the message can name the combo too:
206
+ // with several rows, "the Lead model is gone" does not say which row to open.
207
+ const stale = draft.flatMap((combo, index) => missingRoutes(combo.pair, state.catalog).map(({ role, route }) =>
208
+ tr(lang, `第 ${index + 1} 个组合「${combo.name.trim() || tr(lang, '未命名组合', 'Untitled combo')}」的 ${roleLabel(role)} 模型已不可用:${route.provider}/${route.model}。请为该角色重新选择模型。`,
209
+ `Combo ${index + 1} "${combo.name.trim() || 'Untitled combo'}" still names a ${roleLabel(role)} model that is no longer available: ${route.provider}/${route.model}. Choose a model for that role again.`)))
210
+ if (stale.length) { setError(stale.join(tr(lang, ' ', ' '))); return }
193
211
  setSaving(true); setError(undefined); setNotice(undefined)
194
212
  try {
195
213
  const pairs = draft.map(({ id, name, pair }) => {
@@ -238,6 +256,8 @@ export function FusionSettings() {
238
256
  <span className="fusion-combo-text">
239
257
  <span className="fusion-combo-name">{label}</span>
240
258
  <span className="fusion-combo-routes">{`${routeName(combo.pair.lead)} → ${routeName(combo.pair.worker)}`}</span>
259
+ {missingRoutes(combo.pair, state.catalog).length > 0 && <span className="fusion-combo-stale">
260
+ {tr(lang, '有模型已不可用,需要重新选择', 'A model is no longer available; choose it again')}</span>}
241
261
  </span>
242
262
  </button>
243
263
  <button type="button" className="fusion-combo-actions" disabled={saving || draft.length <= 1}
package/src/host/pairs.ts CHANGED
@@ -155,7 +155,9 @@ export function validatePairs(value: unknown, catalog: SettingsCatalog, existing
155
155
  const name = pairName(raw.name, index === 0 ? DEFAULT_PAIR_NAME : `组合 ${index + 1}`)
156
156
  if (names.has(name)) throw new Error(bi(`组合名称 "${name}" 重复`, `The combo name "${name}" is used twice`))
157
157
  names.add(name)
158
- return { id, name, pair: validatePairChoice(raw.pair, catalog) }
158
+ // The name goes in so a stale route in the second row does not read like a
159
+ // complaint about the first one.
160
+ return { id, name, pair: validatePairChoice(raw.pair, catalog, name) }
159
161
  })
160
162
  }
161
163
 
@@ -27,20 +27,49 @@ export function savedLeadRoute(value: unknown): { provider: string; model: strin
27
27
  return { provider, model }
28
28
  }
29
29
 
30
+ /** The roles a saved pair names, in the order the settings page shows them. */
31
+ export type PairRole = 'lead' | 'worker' | 'compactor'
32
+
33
+ /**
34
+ * The routes a saved choice still names that the current catalog no longer
35
+ * offers: a provider that was renamed or removed, or a model that was retired.
36
+ *
37
+ * Reading a saved combo tolerates a stale route so the row stays visible and
38
+ * editable, but saving one does not. Without this, the page can only say that
39
+ * *some* model is gone, which is unusable when the stale route is not the one
40
+ * being edited — the user then cannot tell which combo to fix.
41
+ */
42
+ export function missingRoutes(value: Partial<PairChoice> | undefined, catalog: SettingsCatalog): { role: PairRole; route: PhysicalRoute }[] {
43
+ if (!value) return []
44
+ const stale = (role: PairRole, route: PhysicalRoute | undefined) => route !== undefined
45
+ && !catalog.groups.find(group => group.id === route.provider)?.models.find(model => model.id === route.model)
46
+ ? [{ role, route }] : []
47
+ return [...stale('lead', value.lead), ...stale('worker', value.worker), ...stale('compactor', value.compactor?.route)]
48
+ }
49
+
30
50
  /** Configuration chooses registered physical models; it cannot create adapters. */
31
- export function validatePairChoice(value: unknown, catalog: SettingsCatalog): PairChoice {
32
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(bi('请选择 Lead 和 Worker', 'Choose a Lead and a Sidekick'))
51
+ export function validatePairChoice(value: unknown, catalog: SettingsCatalog, combo?: string): PairChoice {
52
+ // A list is validated row by row, so the failure has to say which row it came
53
+ // from; `bi` carries both languages, so the label joins the message here
54
+ // instead of being glued onto an already-bilingual string.
55
+ const say = (zh: string, en: string) => combo
56
+ ? bi(`组合「${combo}」:${zh}`, `Combo "${combo}": ${en}`) : bi(zh, en)
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(say('请选择 Lead 和 Worker', 'Choose a Lead and a Sidekick'))
33
58
  const raw = value as Record<string, unknown>
34
59
  const route = (role: string, candidate: unknown): PhysicalRoute => {
35
60
  const item = candidate as Record<string, unknown> | undefined
36
- if (!item || typeof item.provider !== 'string' || typeof item.model !== 'string') throw new Error(bi(`请选择 ${role} 模型`, `Choose the ${role} model`))
37
- if (isFusionProvider(item.provider)) throw new Error(bi('Fusion 必须选择真实模型', 'Fusion needs real models, not Fusion itself'))
61
+ if (!item || typeof item.provider !== 'string' || typeof item.model !== 'string') throw new Error(say(`请选择 ${role} 模型`, `Choose the ${role} model`))
62
+ if (isFusionProvider(item.provider)) throw new Error(say('Fusion 必须选择真实模型', 'Fusion needs real models, not Fusion itself'))
38
63
  const group = catalog.groups.find(group => group.id === item.provider)
39
64
  const model = group?.models.find(model => model.id === item.model)
40
- if (!model) throw new Error(bi(`${role} 模型已不可用,请刷新后重选`, `The ${role} model is no longer available; refresh and choose again`))
65
+ // Name the exact route: after a provider rename the model usually still
66
+ // exists elsewhere, so "refresh and choose again" alone leaves the user
67
+ // guessing which of the two roles went stale.
68
+ if (!model) throw new Error(say(`${role} 模型已不可用:${item.provider}/${item.model}。请为该角色重新选择模型`,
69
+ `The ${role} model is no longer available: ${item.provider}/${item.model}. Choose a model for that role again`))
41
70
  if (item.reasoningEffort !== undefined && (typeof item.reasoningEffort !== 'string'
42
71
  || !model.reasoning?.efforts.some(effort => effort.id === item.reasoningEffort))) {
43
- throw new Error(bi(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`))
72
+ throw new Error(say(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`))
44
73
  }
45
74
  return { provider: item.provider, model: item.model,
46
75
  ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort as string }) }
@@ -53,7 +82,7 @@ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): Pa
53
82
  if (!value || typeof value !== 'object' || Array.isArray(value)
54
83
  || Object.entries(value).some(([role, tokens]) => !['lead', 'worker'].includes(role)
55
84
  || !Number.isSafeInteger(tokens) || Number(tokens) < 1 || Number(tokens) > 128_000)) {
56
- throw new Error(bi('每次响应上限必须是 1–128000 的整数 Token 数', 'The per-response limit must be an integer of 1–128000 tokens'))
85
+ throw new Error(say('每次响应上限必须是 1–128000 的整数 Token 数', 'The per-response limit must be an integer of 1–128000 tokens'))
57
86
  }
58
87
  outputTokens = { ...value } as PairChoice['outputTokens']
59
88
  }