@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/CHANGELOG.md +29 -0
- package/lib/client.js +71 -21
- package/lib/client.js.map +1 -1
- package/lib/dsh-fusion.js +9 -8
- package/lib/types/host/settings.d.ts +16 -1
- package/package.json +1 -1
- package/src/client/FusionSettings.tsx +20 -0
- package/src/host/pairs.ts +3 -1
- package/src/host/settings.ts +36 -7
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
|
-
|
|
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(
|
|
617
|
-
if (isFusionProvider(item.provider)) throw new Error(
|
|
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(
|
|
620
|
-
if (item.reasoningEffort !== void 0 && (typeof item.reasoningEffort !== "string" || !model.reasoning?.efforts.some((effort) => effort.id === item.reasoningEffort))) throw new Error(
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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
|
|
package/src/host/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
37
|
-
if (isFusionProvider(item.provider)) throw new Error(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|