@sqdr2/dsh-fusion 0.3.2 → 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 +177 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/RELEASING.md +8 -2
- package/RELEASING.zh.md +7 -2
- package/dshx.yml +3 -0
- package/lib/client.js +356 -88
- package/lib/client.js.map +1 -1
- package/lib/dsh-fusion.js +590 -220
- package/lib/types/client/FusionPermission.d.ts +15 -0
- package/lib/types/client/FusionSettings.d.ts +9 -1
- package/lib/types/client/status-polling.d.ts +6 -2
- package/lib/types/contracts.d.ts +2 -0
- package/lib/types/host/cache-policy.d.ts +16 -6
- package/lib/types/host/native-role-sandbox.d.ts +10 -2
- package/lib/types/host/native-selection.d.ts +15 -5
- package/lib/types/host/pairs.d.ts +47 -0
- package/lib/types/host/settings.d.ts +23 -4
- package/lib/types/identity.d.ts +16 -14
- package/lib/types/status.d.ts +17 -1
- package/package.json +62 -60
- package/src/client/FusionHint.tsx +11 -4
- package/src/client/FusionPermission.tsx +52 -0
- package/src/client/FusionSettings.tsx +129 -30
- package/src/client/index.ts +10 -5
- package/src/client/status-polling.ts +9 -4
- package/src/contracts.ts +2 -0
- package/src/dsh-fusion.ts +130 -54
- package/src/host/cache-policy.ts +18 -6
- package/src/host/native-role-sandbox.ts +18 -6
- package/src/host/native-scopes.ts +2 -2
- package/src/host/native-selection.ts +65 -21
- package/src/host/native-worker-brief.ts +18 -5
- package/src/host/pairs.ts +180 -0
- package/src/host/settings.ts +45 -12
- package/src/host/status.ts +24 -1
- package/src/identity.ts +39 -16
- package/src/status.ts +17 -1
|
@@ -3,18 +3,24 @@ 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'
|
|
9
10
|
|
|
11
|
+
/** One saved combo as the page shows it: its id, its label and its pair. */
|
|
12
|
+
export interface SavedPairView { id: string; name: string; pair: PairChoice; authorized: boolean; reason?: string }
|
|
10
13
|
interface SettingsState {
|
|
11
14
|
revision: number
|
|
12
15
|
catalog: SettingsCatalog
|
|
13
|
-
|
|
16
|
+
pairs: SavedPairView[]
|
|
17
|
+
maxPairs: number
|
|
14
18
|
authorized: boolean
|
|
15
19
|
managedAuthorization?: boolean
|
|
16
20
|
reason?: string
|
|
17
21
|
}
|
|
22
|
+
/** A row being edited. `id` is absent until the list is saved for the first time. */
|
|
23
|
+
interface DraftCombo { key: string; id?: string; name: string; pair: Partial<PairChoice> }
|
|
18
24
|
const styles = `
|
|
19
25
|
.fusion-page{box-sizing:border-box;width:100%;height:100%;min-width:0;min-height:0;flex:1 1 auto;overflow:auto;display:flex;flex-direction:column;align-items:center;scrollbar-gutter:stable;padding:0 clamp(24px,4vw,48px) 48px;--dsh-scrollbar-width:9px;--dsh-scrollbar-thumb-border:2px}
|
|
20
26
|
.fusion-settings{box-sizing:border-box;width:100%;max-width:680px;margin:0 auto;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:18px}
|
|
@@ -36,8 +42,28 @@ const styles = `
|
|
|
36
42
|
.fusion-settings details{font-size:13px;line-height:21px}
|
|
37
43
|
.fusion-settings summary{cursor:pointer;font-weight:500}
|
|
38
44
|
.fusion-settings .fusion-limits{display:flex;flex-direction:column;gap:12px;padding-top:14px}
|
|
39
|
-
.fusion-settings input[type=number]{min-width:0;width:100%;box-sizing:border-box;background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:8px;font:inherit}
|
|
45
|
+
.fusion-settings :is(input[type=number],input[type=text]){min-width:0;width:100%;box-sizing:border-box;background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:8px;font:inherit}
|
|
40
46
|
.fusion-settings .fusion-consent{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}
|
|
47
|
+
.fusion-combos{display:flex;flex-direction:column;gap:8px}
|
|
48
|
+
.fusion-settings .fusion-combo{display:flex;align-items:stretch;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background-color:var(--dsw-alias-bg-layer-1);font-size:13px;overflow:hidden;transition:border-color .15s ease,background-color .15s ease}
|
|
49
|
+
.fusion-settings .fusion-combo:hover{background-color:var(--dsw-alias-interactive-bg-hover)}
|
|
50
|
+
.fusion-settings .fusion-combo[data-active=true]{border-color:var(--dsw-alias-brand-primary);background-color:var(--dsw-alias-interactive-bg-hover-accent)}
|
|
51
|
+
.fusion-settings .fusion-combo-main{flex:1 1 auto;display:flex;align-items:center;gap:10px;min-width:0;padding:11px 12px;background:none;border:0;border-radius:0;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
52
|
+
.fusion-settings .fusion-combo-main:hover{background:none}
|
|
53
|
+
.fusion-settings .fusion-combo :is(.fusion-combo-main,.fusion-combo-actions):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
54
|
+
.fusion-settings .fusion-combo-check{position:relative;flex:none;width:14px;height:14px;border:1.5px solid var(--dsw-alias-border-l2);border-radius:50%;transition:border-color .15s ease}
|
|
55
|
+
.fusion-settings .fusion-combo:hover .fusion-combo-check{border-color:var(--dsw-alias-label-tertiary)}
|
|
56
|
+
.fusion-settings .fusion-combo[data-active=true] .fusion-combo-check{border-color:var(--dsw-alias-brand-primary)}
|
|
57
|
+
.fusion-settings .fusion-combo[data-active=true] .fusion-combo-check::after{content:'';position:absolute;inset:2.5px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
|
|
58
|
+
.fusion-settings .fusion-combo-text{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
59
|
+
.fusion-settings .fusion-combo-name{font-weight:600;overflow-wrap:anywhere}
|
|
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}
|
|
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}
|
|
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)}
|
|
64
|
+
.fusion-settings .fusion-combo-actions:disabled{opacity:.4;cursor:default}
|
|
65
|
+
.fusion-settings .fusion-combo-add{align-self:flex-start;padding:8px 16px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:none;color:var(--dsw-alias-label-secondary);font-size:13px}
|
|
66
|
+
.fusion-settings .fusion-combo-add:hover:not(:disabled){background-color:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
41
67
|
.fusion-cache{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:16px;display:flex;flex-direction:column;gap:12px}
|
|
42
68
|
.fusion-cache h3{margin:0}
|
|
43
69
|
.fusion-cache-summary{cursor:pointer;font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:space-between;list-style:none;user-select:none;padding:2px 0}
|
|
@@ -103,6 +129,10 @@ export function ModelChoice({ role, route, catalog, onChange }: {
|
|
|
103
129
|
const lang = useLang()
|
|
104
130
|
const name = role === 'compactor' ? tr(lang, '压缩', 'Compaction') : role
|
|
105
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
|
|
106
136
|
return <fieldset>
|
|
107
137
|
<legend>{name}</legend>
|
|
108
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>
|
|
@@ -112,10 +142,13 @@ export function ModelChoice({ role, route, catalog, onChange }: {
|
|
|
112
142
|
onChange({ provider, model })
|
|
113
143
|
}}>
|
|
114
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>}
|
|
115
146
|
{catalog.groups.map(group => <optgroup key={group.id} label={group.name}>
|
|
116
147
|
{group.models.map(model => <option key={model.id} value={key({ provider: group.id, model: model.id })}>{model.name}</option>)}
|
|
117
148
|
</optgroup>)}
|
|
118
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>}
|
|
119
152
|
{model?.reasoning && route && <label>{tr(lang, '推理强度', 'Reasoning')}<select aria-label={tr(lang, `${name} 推理强度`, `${name} reasoning`)} value={route.reasoningEffort ?? ''} onChange={event => {
|
|
120
153
|
const { reasoningEffort: _old, ...physical } = route
|
|
121
154
|
onChange({ ...physical, ...(event.target.value ? { reasoningEffort: event.target.value } : {}) })
|
|
@@ -126,76 +159,142 @@ export function ModelChoice({ role, route, catalog, onChange }: {
|
|
|
126
159
|
</fieldset>
|
|
127
160
|
}
|
|
128
161
|
|
|
162
|
+
let draftKeys = 0
|
|
163
|
+
const toDraft = (pairs: readonly SavedPairView[]): DraftCombo[] => pairs.map(({ id, name, pair }) => ({ key: id, id, name, pair }))
|
|
164
|
+
|
|
129
165
|
export function FusionSettings() {
|
|
130
166
|
const lang = useLang()
|
|
131
167
|
const [state, setState] = useState<SettingsState>()
|
|
132
|
-
const [draft, setDraft] = useState<
|
|
168
|
+
const [draft, setDraft] = useState<DraftCombo[]>([])
|
|
169
|
+
const [active, setActive] = useState(0)
|
|
133
170
|
const [error, setError] = useState<string>()
|
|
134
171
|
const [notice, setNotice] = useState<string>()
|
|
135
172
|
const [saving, setSaving] = useState(false)
|
|
136
|
-
const
|
|
137
|
-
|
|
173
|
+
const current = draft[Math.min(active, draft.length - 1)]
|
|
174
|
+
const routeName = (route?: PhysicalRoute) => route
|
|
175
|
+
? state?.catalog.groups.find(group => group.id === route.provider)?.models.find(model => model.id === route.model)?.name
|
|
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')
|
|
179
|
+
const update = (change: (combo: DraftCombo) => DraftCombo) => setDraft(value => value.map((combo, index) =>
|
|
180
|
+
combo.key === current?.key ? change(combo) : combo))
|
|
181
|
+
const chooseModel = (role: 'lead' | 'worker', route: PhysicalRoute) => update(combo => ({ ...combo, pair: { ...combo.pair, [role]: route,
|
|
182
|
+
...(combo.pair.cacheKeepalive ? { cacheKeepalive: { ...combo.pair.cacheKeepalive, [role]: role === 'lead' ? 'auto' : false } } : {}) } }))
|
|
138
183
|
useEffect(() => {
|
|
139
184
|
const abort = new AbortController()
|
|
140
185
|
void fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
141
186
|
.then(async response => { if (!response.ok) throw new Error(tr(lang, '无法读取 Fusion 设置', 'Cannot read Fusion settings')); return response.json() as Promise<SettingsState> })
|
|
142
|
-
.then(value => { setState(value); setDraft(value.
|
|
187
|
+
.then(value => { setState(value); setDraft(toDraft(value.pairs)); setActive(0) })
|
|
143
188
|
.catch(error => { if (!abort.signal.aborted) setError(String(error)) })
|
|
144
189
|
return () => abort.abort()
|
|
145
190
|
}, [])
|
|
191
|
+
const addCombo = () => {
|
|
192
|
+
draftKeys += 1
|
|
193
|
+
setDraft(value => [...value, { key: `new-${draftKeys}`, name: tr(lang, `组合 ${value.length + 1}`, `Combo ${value.length + 1}`), pair: {} }])
|
|
194
|
+
setActive(draft.length)
|
|
195
|
+
}
|
|
196
|
+
const removeCombo = (index: number) => {
|
|
197
|
+
setDraft(value => value.filter((_, position) => position !== index))
|
|
198
|
+
setActive(Math.max(0, Math.min(active > index ? active - 1 : active, draft.length - 2)))
|
|
199
|
+
}
|
|
146
200
|
const save = async () => {
|
|
147
|
-
if (!state
|
|
201
|
+
if (!state) return
|
|
202
|
+
const invalid = draft.findIndex(combo => !combo.pair.lead || !combo.pair.worker || !combo.name.trim())
|
|
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 }
|
|
148
211
|
setSaving(true); setError(undefined); setNotice(undefined)
|
|
149
212
|
try {
|
|
150
|
-
const {
|
|
151
|
-
|
|
152
|
-
|
|
213
|
+
const pairs = draft.map(({ id, name, pair }) => {
|
|
214
|
+
const { outputTokens, ...rest } = pair
|
|
215
|
+
const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0))
|
|
216
|
+
return { ...(id ? { id } : {}), name: name.trim(), pair: { ...rest, ...(Object.keys(limits).length ? { outputTokens: limits } : {}) } }
|
|
217
|
+
})
|
|
153
218
|
const response = await fetch(FUSION_API_PATH, { method: 'POST', credentials: 'same-origin',
|
|
154
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'configure', revision: state.revision,
|
|
219
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'configure', revision: state.revision, pairs }) })
|
|
155
220
|
const result = await response.json() as { revision: number; error?: string }
|
|
156
221
|
if (!response.ok) throw new Error(result.error ?? tr(lang, '保存失败,请刷新后重试', 'Save failed; refresh and try again'))
|
|
157
|
-
const
|
|
158
|
-
if (!
|
|
159
|
-
const
|
|
160
|
-
setState(
|
|
222
|
+
const confirmed = await fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store' })
|
|
223
|
+
if (!confirmed.ok) throw new Error(tr(lang, '配置已提交,但暂时无法确认,请刷新', 'Saved, but not confirmed yet; refresh'))
|
|
224
|
+
const next = await confirmed.json() as SettingsState
|
|
225
|
+
setState(next); setDraft(toDraft(next.pairs)); setActive(0)
|
|
161
226
|
settingsChanged()
|
|
162
|
-
setNotice(tr(lang, '
|
|
227
|
+
setNotice(tr(lang, '组合已保存。模型菜单中每个组合各占一项,不同会话可以同时使用不同组合。', 'Combos saved. Each one is its own model-menu entry, so different conversations can use different combos at the same time.'))
|
|
163
228
|
} catch (error) { setError(error instanceof Error ? error.message : String(error)) }
|
|
164
229
|
finally { setSaving(false) }
|
|
165
230
|
}
|
|
231
|
+
const dirty = state ? JSON.stringify(draft.map(({ id, name, pair }) => ({ id, name, pair })))
|
|
232
|
+
!== JSON.stringify(state.pairs.map(({ id, name, pair }) => ({ id, name, pair }))) : false
|
|
233
|
+
const blocked = state?.pairs.filter(pair => !pair.authorized) ?? []
|
|
166
234
|
return <div className="fusion-page">
|
|
167
235
|
<style>{styles}</style>
|
|
168
236
|
<div className="fusion-settings">
|
|
169
|
-
<header data-window-drag><h2>Fusion</h2
|
|
237
|
+
<header data-window-drag><h2>Fusion</h2>
|
|
238
|
+
<p>{tr(lang, '每个组合是一对 Lead 和 Sidekick,会作为单独的一项出现在模型菜单里。开启多个会话时,可以为每个会话选择不同的组合。', 'Each combo is a Lead and a Sidekick pair, listed as its own entry in the model menu. With several conversations open, each one can use a different combo.')}</p>
|
|
239
|
+
<p>{tr(lang, 'Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。', 'The Lead handles requests, plans and reviews; the Sidekick does the delegated work.')}</p>
|
|
170
240
|
<p><b>{tr(lang, '搭配建议', 'Pairing tip')}</b>: {tr(lang, 'Fusion 省钱靠的是 Sidekick 比 Lead 便宜得多。选一个强的前沿模型当 Lead,再选一个价格低很多(最好 5 倍以上)的模型当 Sidekick;两者价格越接近,越省不了钱。',
|
|
171
241
|
'Fusion saves money because the Sidekick is much cheaper than the Lead. Pick a strong frontier Lead and a Sidekick that costs far less (ideally 5× or more); the closer their prices, the smaller the saving.')}</p></header>
|
|
172
242
|
{error && <p role="alert">{error}</p>}
|
|
173
243
|
{!state && !error && <p role="status">{tr(lang, '正在读取模型…', 'Loading models…')}</p>}
|
|
174
244
|
{state && <>
|
|
175
|
-
<
|
|
176
|
-
<
|
|
177
|
-
|
|
245
|
+
<section className="fusion-card" aria-label={tr(lang, '组合列表', 'Combo list')}>
|
|
246
|
+
<div className="fusion-combos">
|
|
247
|
+
{draft.map((combo, index) => {
|
|
248
|
+
const selected = combo.key === current?.key
|
|
249
|
+
const label = combo.name.trim() || tr(lang, '未命名组合', 'Untitled combo')
|
|
250
|
+
// The whole row is the selection target: the button spans the row up to the Delete
|
|
251
|
+
// button, so the padding and the empty space beside the text select the combo too.
|
|
252
|
+
return <div className="fusion-combo" key={combo.key} data-active={selected}>
|
|
253
|
+
<button type="button" className="fusion-combo-main" aria-pressed={selected}
|
|
254
|
+
onClick={() => setActive(index)}>
|
|
255
|
+
<span className="fusion-combo-check" aria-hidden="true" />
|
|
256
|
+
<span className="fusion-combo-text">
|
|
257
|
+
<span className="fusion-combo-name">{label}</span>
|
|
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>}
|
|
261
|
+
</span>
|
|
262
|
+
</button>
|
|
263
|
+
<button type="button" className="fusion-combo-actions" disabled={saving || draft.length <= 1}
|
|
264
|
+
aria-label={tr(lang, `删除组合 ${label}`, `Delete combo ${label}`)}
|
|
265
|
+
onClick={event => { event.stopPropagation(); removeCombo(index) }}>{tr(lang, '删除', 'Delete')}</button>
|
|
266
|
+
</div>
|
|
267
|
+
})}
|
|
268
|
+
{!draft.length && <p>{tr(lang, '还没有组合。新增一个,选择 Lead 和 Sidekick 后保存。', 'No combos yet. Add one, choose a Lead and a Sidekick, then save.')}</p>}
|
|
269
|
+
</div>
|
|
270
|
+
<button type="button" className="fusion-combo-add" disabled={saving || draft.length >= state.maxPairs} onClick={addCombo}>{tr(lang, '+ 新增组合', '+ Add combo')}</button>
|
|
271
|
+
<p>{tr(lang, `最多保存 ${state.maxPairs} 个组合;模型菜单中的名称是「Fusion · 组合名称」。`, `Up to ${state.maxPairs} combos; the model menu shows them as “Fusion · name”.`)}</p>
|
|
272
|
+
</section>
|
|
273
|
+
{current && <fieldset className="fusion-card" disabled={saving}>
|
|
274
|
+
<label>{tr(lang, '名称', 'Name')}<input type="text" maxLength={40} aria-label={tr(lang, '组合名称', 'Combo name')}
|
|
275
|
+
value={current.name} onChange={event => update(combo => ({ ...combo, name: event.target.value }))} /></label>
|
|
276
|
+
<ModelChoice role="Lead" route={current.pair.lead} catalog={state.catalog} onChange={route => { if (route) chooseModel('lead', route) }} />
|
|
277
|
+
<ModelChoice role="Sidekick" route={current.pair.worker} catalog={state.catalog} onChange={route => { if (route) chooseModel('worker', route) }} />
|
|
178
278
|
<details><summary>{tr(lang, '上下文压缩模型 · 可选', 'Compaction model · optional')}</summary>
|
|
179
279
|
<div className="fusion-limits">
|
|
180
280
|
<p>{tr(lang, '默认由当前角色模型压缩。也可以单独选择已连接的模型;请求由所选账号计费。', 'By default each role compacts with its own model. You can pick another connected model; its account is billed.')}</p>
|
|
181
|
-
<ModelChoice role="compactor" route={
|
|
182
|
-
const { compactor, ...rest } =
|
|
183
|
-
return route ? { ...rest, compactor: { route, maxOutputTokens: compactor?.maxOutputTokens ?? 8192 } } : rest
|
|
281
|
+
<ModelChoice role="compactor" route={current.pair.compactor?.route} catalog={state.catalog} onChange={route => update(combo => {
|
|
282
|
+
const { compactor, ...rest } = combo.pair
|
|
283
|
+
return { ...combo, pair: route ? { ...rest, compactor: { route, maxOutputTokens: compactor?.maxOutputTokens ?? 8192 } } : rest }
|
|
184
284
|
})} />
|
|
185
|
-
{
|
|
186
|
-
value={
|
|
187
|
-
|
|
188
|
-
<p>{tr(lang, '已选中的会话仍使用原配置。', 'Conversations already using Fusion keep their setup.')}</p>
|
|
285
|
+
{current.pair.compactor && <label>{tr(lang, '输出 Token 上限', 'Output token limit')}<input aria-label={tr(lang, '压缩输出 Token 上限', 'Compaction output token limit')} type="number" min="1" max="128000"
|
|
286
|
+
value={current.pair.compactor.maxOutputTokens} onChange={event => { const maxOutputTokens = Number(event.target.value)
|
|
287
|
+
update(combo => combo.pair.compactor ? { ...combo, pair: { ...combo.pair, compactor: { ...combo.pair.compactor, maxOutputTokens } } } : combo) }} /></label>}
|
|
189
288
|
</div>
|
|
190
289
|
</details>
|
|
191
|
-
</fieldset>
|
|
290
|
+
</fieldset>}
|
|
192
291
|
<div className="fusion-actions"><p>{tr(lang, '已选中的会话保留原组合;新选择使用新组合。额度耗尽后要换当前任务的 Sidekick,用该会话的 Fusion 恢复面板,而不是只改这里。', 'Conversations already using Fusion keep their pair; new selections use the new one. After a quota stop, switch this conversation\'s Sidekick from its Fusion recovery panel, not only here.')}</p>
|
|
193
|
-
<button disabled={saving || !
|
|
292
|
+
<button disabled={saving || !dirty || !draft.length} onClick={() => { void save() }}>{saving ? tr(lang, '正在保存…', 'Saving…') : tr(lang, '保存组合', 'Save combos')}</button>
|
|
194
293
|
</div>
|
|
195
294
|
{notice && <p role="status">{notice}</p>}
|
|
196
295
|
<p>{tr(lang, '输出参数跟随原生模型设置,Fusion 不额外限制请求次数或任务轮数。', 'Output settings follow the native model settings; Fusion adds no request or round limits.')}</p>
|
|
197
296
|
<p>{tr(lang, '保存即表示允许 Fusion 使用所选模型的现有账号;费用由这些账号计入,当前未提供可靠的实际账单金额。', 'Saving lets Fusion use the chosen models\' existing accounts; they are billed there, and no reliable billed amount is available here.')}</p>
|
|
198
|
-
{
|
|
297
|
+
{blocked.map(pair => <p key={pair.id} role="status">{`${pair.name}: ${tr(lang, '尚未就绪,需要重新保存这个组合', 'not ready yet; save this combo again')}${pair.reason ? `: ${pair.reason}` : ''}`}</p>)}
|
|
199
298
|
{state.managedAuthorization && <p>{tr(lang, '运行额度由外部授权配置管理。', 'Run limits are managed by an external authorization file.')}</p>}
|
|
200
299
|
</>}
|
|
201
300
|
<FusionCache refresh={state?.revision ?? 0} />
|
package/src/client/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
|
6
6
|
import { FusionSettings } from './FusionSettings.js'
|
|
7
7
|
import { FusionPanelIcon } from './FusionPanelIcon.js'
|
|
8
8
|
import { FusionHint } from './FusionHint.js'
|
|
9
|
+
import { FusionPermission } from './FusionPermission.js'
|
|
9
10
|
import { FusionTool, fusionControlTools } from './FusionTool.js'
|
|
10
11
|
import { bindLocale } from './i18n.js'
|
|
11
12
|
|
|
@@ -39,12 +40,16 @@ export function apply(ctx: Context): void {
|
|
|
39
40
|
name: 'sidebar.panellist', id: FUSION_PANEL_ID, order: 40, label: () => 'Fusion',
|
|
40
41
|
}, FusionPanelIcon))
|
|
41
42
|
ctx.inject(['modelDirectories'], scope => {
|
|
43
|
+
const sessionProps = (sessionId: Parameters<typeof scope.modelDirectories.directoryFor>[0]) => {
|
|
44
|
+
const directory = scope.modelDirectories.directoryFor(sessionId)
|
|
45
|
+
return { sessionId, directory: directory.store, load: () => { void directory.load().catch(() => undefined) } }
|
|
46
|
+
}
|
|
42
47
|
scope.slots.inject('conversation.input.dock', () => scope.slots.register({
|
|
43
|
-
name: 'conversation.input.dock', id: 'fusion-configuration', order: 20,
|
|
44
|
-
inject: sessionId => {
|
|
45
|
-
const directory = scope.modelDirectories.directoryFor(sessionId)
|
|
46
|
-
return { sessionId, directory: directory.store, load: () => { void directory.load().catch(() => undefined) } }
|
|
47
|
-
},
|
|
48
|
+
name: 'conversation.input.dock', id: 'fusion-configuration', order: 20, inject: sessionProps,
|
|
48
49
|
}, FusionHint))
|
|
50
|
+
// Additive: the native permission control keeps its slot and its selection state.
|
|
51
|
+
scope.slots.inject('conversation.input.left', () => scope.slots.register({
|
|
52
|
+
name: 'conversation.input.left', id: 'fusion-permission', order: 10, inject: sessionProps,
|
|
53
|
+
}, FusionPermission))
|
|
49
54
|
})
|
|
50
55
|
}
|
|
@@ -72,13 +72,18 @@ function subscribe(sessionId: string, listener: () => void, expanded: () => bool
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
/**
|
|
76
|
+
* One in-flight read per mounted session; collapsed hints still discover new attention states.
|
|
77
|
+
* `live` asks for the fast cadence whenever the task is running, for a view whose whole
|
|
78
|
+
* purpose is the current stage (the role and permission of the model working now).
|
|
79
|
+
*/
|
|
80
|
+
export function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly = false, live = false): Snapshot | undefined {
|
|
81
|
+
const expandedRef = useRef(expandedTask), attentionRef = useRef(attentionOnly), liveRef = useRef(live)
|
|
82
|
+
expandedRef.current = expandedTask; attentionRef.current = attentionOnly; liveRef.current = live
|
|
79
83
|
useEffect(() => { if (expandedTask) entries.get(sessionId)?.visibility() }, [sessionId, expandedTask])
|
|
80
84
|
return useSyncExternalStore(
|
|
81
85
|
useCallback(listener => subscribe(sessionId, listener, () => {
|
|
86
|
+
if (liveRef.current) return true
|
|
82
87
|
const task = entries.get(sessionId)?.snapshot?.status?.task
|
|
83
88
|
return expandedRef.current !== undefined && task?.id === expandedRef.current && (!attentionRef.current || task.attention)
|
|
84
89
|
}), [sessionId]),
|
package/src/contracts.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type RunSelection =
|
|
|
40
40
|
| { kind: 'profile'; profileId: string; requestedVersion?: string }
|
|
41
41
|
|
|
42
42
|
export type Role = 'lead' | 'worker'
|
|
43
|
+
/** A Host sandbox mode, spelled as the `sandbox/mode` session event writes it. */
|
|
44
|
+
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
43
45
|
export type Phase =
|
|
44
46
|
| 'READY'
|
|
45
47
|
| 'PLANNING'
|
package/src/dsh-fusion.ts
CHANGED
|
@@ -13,8 +13,10 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-commands'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
15
15
|
import { FusionCatalogAdapter, FUSION_PROVIDER, FUSION_MODEL, installNativeFusionSelection } from './host/native-selection.js'
|
|
16
|
+
import type { FusionModelEntry } from './host/native-selection.js'
|
|
16
17
|
import type { ResolvedProfile } from './profile/resolve.js'
|
|
17
|
-
import { choiceFromProfile, modelProfileFromChoice, validatePairChoice,
|
|
18
|
+
import { choiceFromProfile, modelProfileFromChoice, validatePairChoice, type PairChoice } from './host/settings.js'
|
|
19
|
+
import { MAX_PAIRS, choiceFromStoredProfile, modelOfProfileId, profileIdOf, readPairs, validatePairs, writePairs } from './host/pairs.js'
|
|
18
20
|
import { CachePolicy, cacheView } from './host/cache-policy.js'
|
|
19
21
|
import { CACHE_DEFAULTS_CHECKED } from './host/cache-defaults.js'
|
|
20
22
|
import { loadJsonObject, resolveProfile } from './profile/resolve.js'
|
|
@@ -55,22 +57,71 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
55
57
|
const cachePolicy = new CachePolicy(store)
|
|
56
58
|
backfillHistory(store)
|
|
57
59
|
const budget = new NativeRequestBudget(store, config.authorizationPath)
|
|
58
|
-
let defaultProfile: ResolvedProfile | undefined
|
|
59
60
|
const physicalCatalog = async () => {
|
|
60
61
|
const catalog = await ctx.sessionController.modelCatalog()
|
|
61
62
|
return { ...catalog, groups: catalog.groups.filter(group => !isFusionProvider(group.id)) }
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The combos to resolve: the saved list, or the one profile file a benchmark
|
|
66
|
+
* deployment names. A profile file keeps its legacy contract, so only saved
|
|
67
|
+
* combos are rebuilt from a choice.
|
|
68
|
+
*/
|
|
69
|
+
const comboPlan = (): { revision: number; items: readonly { id: string; name: string; pair: PairChoice; raw?: Record<string, unknown> }[] } => {
|
|
70
|
+
const snapshot = readPairs(store)
|
|
71
|
+
if (snapshot.pairs.length) return { revision: snapshot.revision, items: snapshot.pairs }
|
|
72
|
+
if (config.profilePath && isAbsolute(config.profilePath)) {
|
|
73
|
+
const raw = loadJsonObject(config.profilePath)
|
|
74
|
+
const pair = choiceFromStoredProfile(raw)
|
|
75
|
+
if (pair) return { revision: snapshot.revision, items: [{ id: FUSION_MODEL, name: '自动', pair, raw }] }
|
|
76
|
+
}
|
|
77
|
+
return { revision: snapshot.revision, items: [] }
|
|
78
|
+
}
|
|
79
|
+
interface Combos {
|
|
80
|
+
revision: number
|
|
81
|
+
items: readonly { id: string; name: string; pair: PairChoice }[]
|
|
82
|
+
/** Frozen profile of every usable combo, keyed by the model id it advertises. */
|
|
83
|
+
profiles: Map<string, ResolvedProfile>
|
|
84
|
+
/** Why one saved combo cannot run yet; the other combos stay usable. */
|
|
85
|
+
failures: Map<string, string>
|
|
86
|
+
}
|
|
87
|
+
let combos: Combos | undefined
|
|
88
|
+
const resolveCombos = async (): Promise<Combos> => {
|
|
89
|
+
const plan = comboPlan()
|
|
68
90
|
const catalog = await physicalCatalog()
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
const authorizedRoutes = catalog.groups.flatMap(group => group.models.map(model => ({ provider: group.id, model: model.id })))
|
|
92
|
+
const profiles = new Map<string, ResolvedProfile>(), failures = new Map<string, string>()
|
|
93
|
+
for (const item of plan.items) {
|
|
94
|
+
try {
|
|
95
|
+
const pair = validatePairChoice(item.pair, catalog)
|
|
96
|
+
const next = item.raw ?? modelProfileFromChoice(pair, profileIdOf(item.id))
|
|
97
|
+
profiles.set(item.id, resolveProfile(next, next.interactionMode === 'model-like' ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes }))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// One retired model must not take every other combo down with it.
|
|
100
|
+
failures.set(item.id, error instanceof Error ? error.message : String(error))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
combos = { revision: plan.revision, items: plan.items.map(({ id, name, pair }) => ({ id, name, pair })), profiles, failures }
|
|
104
|
+
return combos
|
|
105
|
+
}
|
|
106
|
+
const knownCombos = async (): Promise<Combos> => combos ?? await resolveCombos()
|
|
107
|
+
// A combo list saved from another process (or before the model catalog was
|
|
108
|
+
// ready at startup) is picked up on first use instead of failing the session.
|
|
109
|
+
let reloading = false
|
|
110
|
+
const profileOf = (model: string): ResolvedProfile | undefined => {
|
|
111
|
+
const found = combos?.profiles.get(model)
|
|
112
|
+
if (!found && !reloading) {
|
|
113
|
+
reloading = true
|
|
114
|
+
void resolveCombos().catch(() => undefined).finally(() => { reloading = false })
|
|
115
|
+
}
|
|
116
|
+
return found
|
|
117
|
+
}
|
|
118
|
+
/** The profile a plain (unselected) request falls back to: the first usable combo. */
|
|
119
|
+
const primaryProfile = async (): Promise<ResolvedProfile> => {
|
|
120
|
+
const state = await knownCombos()
|
|
121
|
+
const profile = state.profiles.values().next().value
|
|
122
|
+
if (profile) return profile
|
|
123
|
+
const [first] = state.failures.values()
|
|
124
|
+
throw new Error(first ?? bi('请先在设置的 Fusion 页面选择 Lead 和 Worker', 'Open Settings → Fusion and choose a Lead and a Sidekick first'))
|
|
74
125
|
}
|
|
75
126
|
const needsBudget = (profile: ResolvedProfile['profile']) => Boolean(config.authorizationPath) || profile.interactionMode !== 'model-like'
|
|
76
127
|
let runtime: FusionCoordinator | undefined
|
|
@@ -82,7 +133,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
82
133
|
if (runtime) return Promise.resolve(runtime)
|
|
83
134
|
if (creating) return creating
|
|
84
135
|
creating = (async () => {
|
|
85
|
-
const profile = frozen ?? await
|
|
136
|
+
const profile = frozen ?? await primaryProfile()
|
|
86
137
|
runtime = new FusionCoordinator(ctx, store, { profile, workerTools: config.workerTools ?? defaultWorkerTools,
|
|
87
138
|
resumeAuthorization: binding => needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account',
|
|
88
139
|
authorizeRequest: (_agent, binding) => {
|
|
@@ -107,28 +158,39 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
107
158
|
})().finally(() => { creating = undefined })
|
|
108
159
|
return creating
|
|
109
160
|
}
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// An unresolvable Lead route (unregistered provider, retired model, broken
|
|
121
|
-
// profile file) cannot verify image support; the catalog entry stays text-only.
|
|
122
|
-
return undefined
|
|
161
|
+
// Each combo's entry admits what its own Lead admits: the Lead is the only
|
|
162
|
+
// role that reads user content, while the Worker receives text work orders.
|
|
163
|
+
// This resolves through the other providers' own model info, never through
|
|
164
|
+
// this adapter, so listing Fusion cannot recurse into itself.
|
|
165
|
+
const fusionModels = async (): Promise<readonly FusionModelEntry[]> => {
|
|
166
|
+
let items: readonly { id: string; name: string; pair: PairChoice }[]
|
|
167
|
+
try { items = readPairs(store).pairs } catch { return [] }
|
|
168
|
+
if (!items.length && config.profilePath && isAbsolute(config.profilePath)) {
|
|
169
|
+
const pair = choiceFromStoredProfile(loadJsonObject(config.profilePath))
|
|
170
|
+
items = pair ? [{ id: FUSION_MODEL, name: '自动', pair }] : []
|
|
123
171
|
}
|
|
172
|
+
return Promise.all(items.map(async item => {
|
|
173
|
+
let modalities: readonly ModelModality[] = []
|
|
174
|
+
try {
|
|
175
|
+
// An unresolvable Lead route (unregistered provider, retired model, broken
|
|
176
|
+
// profile file) cannot verify image support; the entry stays text-only.
|
|
177
|
+
modalities = (await ctx.llm.resolveModelInfo(item.pair.lead.provider, item.pair.lead.model)).inputModalities ?? []
|
|
178
|
+
} catch { modalities = [] }
|
|
179
|
+
return { id: item.id, name: `Fusion · ${item.name}`, inputModalities: modalities }
|
|
180
|
+
}))
|
|
124
181
|
}
|
|
125
|
-
ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(
|
|
182
|
+
ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(fusionModels)))
|
|
126
183
|
ctx.effect(() => installNativeFusionSelection(ctx, {
|
|
127
|
-
coordinator: () => runtime,
|
|
184
|
+
coordinator: () => runtime,
|
|
185
|
+
profile: profileOf,
|
|
186
|
+
// A combo that cannot run yet says why, instead of the generic "configure first".
|
|
187
|
+
reason: model => combos?.failures.get(model)
|
|
188
|
+
?? (combos?.items.length ? bi('该组合已被删除或无法使用,请在模型菜单中选择其他组合。', 'That combo was removed or cannot run; pick another one in the model menu.') : undefined),
|
|
128
189
|
selection: agent => {
|
|
129
190
|
const selection = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
|
|
130
191
|
if (selection?.type === 'model/selection') return selection.data
|
|
131
|
-
|
|
192
|
+
const bound = bindings.read(agent.id)?.binding
|
|
193
|
+
if (bound?.selected) return { provider: FUSION_PROVIDER, model: modelOfProfileId(bound.profile.id) ?? FUSION_MODEL }
|
|
132
194
|
const header = agent.session.requestHeader()
|
|
133
195
|
return header ? { provider: header.config.provider, model: header.config.model } : ctx.agentDefaultModel.currentSelection()
|
|
134
196
|
},
|
|
@@ -162,27 +224,35 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
162
224
|
}
|
|
163
225
|
if (url.searchParams.get('view') === 'catalog') return json(res, 200, await ctx.sessionController.modelCatalog())
|
|
164
226
|
if (url.searchParams.get('view') === 'cache') {
|
|
165
|
-
let
|
|
166
|
-
try {
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
let pairs: { lead: { provider: string; model: string }; worker: { provider: string; model: string }; cacheKeepalive?: PairChoice['cacheKeepalive'] }[] = []
|
|
228
|
+
try { pairs = [...(await knownCombos()).profiles.values()].map(profile => ({ lead: profile.profile.lead,
|
|
229
|
+
worker: profile.profile.worker, cacheKeepalive: profile.profile.cacheKeepalive })) } catch { pairs = [] }
|
|
230
|
+
return json(res, 200, { checked: CACHE_DEFAULTS_CHECKED, models: cacheView(cachePolicy, pairs) })
|
|
169
231
|
}
|
|
170
232
|
if (url.searchParams.get('view') === 'settings') {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
profile =
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
// Resolved fresh: the revision a save sends must match the one shown.
|
|
234
|
+
const state = await resolveCombos()
|
|
235
|
+
const pairs = state.items.map(item => {
|
|
236
|
+
const profile = state.profiles.get(item.id)
|
|
237
|
+
if (!profile) return { id: item.id, name: item.name, pair: item.pair, authorized: false, reason: state.failures.get(item.id) }
|
|
238
|
+
let authorized = false, reason: string | undefined
|
|
239
|
+
try { if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile)); authorized = true }
|
|
240
|
+
catch (error) { reason = error instanceof Error ? error.message : String(error) }
|
|
241
|
+
return { id: item.id, name: item.name, pair: choiceFromProfile(profile.profile), authorized, ...(reason ? { reason } : {}) }
|
|
242
|
+
})
|
|
243
|
+
const blocked = pairs.find(pair => !pair.authorized)
|
|
244
|
+
return json(res, 200, { revision: state.revision, catalog: await physicalCatalog(), pairs, maxPairs: MAX_PAIRS,
|
|
245
|
+
// The first combo is what the single-pair page and older tabs read.
|
|
246
|
+
pair: pairs[0]?.pair ?? null, authorized: pairs.length > 0 && !blocked,
|
|
247
|
+
...(blocked?.reason ? { reason: blocked.reason } : {}),
|
|
248
|
+
managedAuthorization: Boolean(config.authorizationPath),
|
|
249
|
+
policy: state.profiles.values().next().value?.profile.interactionMode ?? 'model-like', actualBilledUsd: null, apiEquivalentUsd: null })
|
|
180
250
|
}
|
|
181
251
|
const sessionId = url.searchParams.get('sessionId') ?? ''
|
|
182
252
|
const binding = sessionId && bindings.read(sessionId)?.binding
|
|
183
253
|
let available = false, reason: string | undefined
|
|
184
254
|
try {
|
|
185
|
-
const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await
|
|
255
|
+
const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await primaryProfile()
|
|
186
256
|
const controller = await getRuntime(profile)
|
|
187
257
|
controller.transport.assertAvailable()
|
|
188
258
|
if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile))
|
|
@@ -252,19 +322,25 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
252
322
|
}
|
|
253
323
|
if (body.action === 'configure') {
|
|
254
324
|
if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) throw new Error(bi('配置版本无效,请刷新后重试', 'Invalid settings revision; refresh and retry'))
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
325
|
+
const catalog = await physicalCatalog(), current = readPairs(store)
|
|
326
|
+
// A tab from before combos existed sends one pair; keep the other combos
|
|
327
|
+
// and let it edit only the first one it can see.
|
|
328
|
+
const proposed = Array.isArray(body.pairs) ? body.pairs
|
|
329
|
+
: [{ ...(current.pairs[0] ?? { id: FUSION_MODEL, name: '自动' }), pair: body.pair }, ...current.pairs.slice(1)]
|
|
330
|
+
const pairs = validatePairs(proposed, catalog, current.pairs)
|
|
331
|
+
const revision = writePairs(store, Number(body.revision), pairs)
|
|
332
|
+
const state = await resolveCombos()
|
|
333
|
+
const profile = state.profiles.values().next().value
|
|
334
|
+
if (profile) await getRuntime(profile)
|
|
259
335
|
return json(res, 200, { ok: true, revision })
|
|
260
336
|
}
|
|
261
337
|
if (body.action === 'authorize') {
|
|
262
338
|
if (config.authorizationPath) throw new Error(bi('运行额度由外部授权文件管理,不能在此覆盖', 'Run limits are managed by an external authorization file and cannot be overridden here'))
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
if (store.
|
|
267
|
-
const auth = authorizeConfiguredPair(store, profileRoutes(profile.profile), body.limits)
|
|
339
|
+
if (readPairs(store).revision !== body.revision) throw new Error(bi('组合列表已变化,请刷新后再启用额度', 'The combo list changed; refresh before enabling limits'))
|
|
340
|
+
// One authorization must cover every combo the menu can offer.
|
|
341
|
+
const state = await resolveCombos()
|
|
342
|
+
if (readPairs(store).revision !== body.revision) throw new Error(bi('组合列表已变化,请刷新后再启用额度', 'The combo list changed; refresh before enabling limits'))
|
|
343
|
+
const auth = authorizeConfiguredPair(store, [...state.profiles.values()].flatMap(profile => profileRoutes(profile.profile)), body.limits)
|
|
268
344
|
return json(res, 200, { ok: true, expiresAt: auth.expiresAt })
|
|
269
345
|
}
|
|
270
346
|
throw new Error(bi('请使用原模型菜单选择 Fusion 或切换普通模型', 'Use the model menu to select Fusion or another model'))
|
|
@@ -280,7 +356,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
280
356
|
const [action = 'status', ...note] = invocation.rawInput.trim().split(/\s+/)
|
|
281
357
|
try {
|
|
282
358
|
const binding = bindings.read(invocation.agent.id)?.binding
|
|
283
|
-
if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion
|
|
359
|
+
if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion。请在模型选择器中选择一个 Fusion 组合。', 'Fusion is not selected in this conversation. Choose a Fusion combo in the model menu.') }
|
|
284
360
|
if (action === 'status' || action === '') {
|
|
285
361
|
const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
|
|
286
362
|
const state = store.load(binding.taskId)!
|
|
@@ -312,7 +388,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
312
388
|
} }))
|
|
313
389
|
ctx.effect(() => async () => { if (creating) await creating.catch(() => undefined); if (runtime) await runtime.close(); store.close() })
|
|
314
390
|
// Warm only configuration/scopes; initialization performs no inference.
|
|
315
|
-
try { await getRuntime(await
|
|
391
|
+
try { await getRuntime(await primaryProfile()) }
|
|
316
392
|
catch {
|
|
317
393
|
const selected = bindings.selected()[0]
|
|
318
394
|
if (selected) await getRuntime({ profile: selected.profile, prompts: selected.prompts })
|