@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.
@@ -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
- pair: PairChoice | null
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<Partial<PairChoice>>({})
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 chooseModel = (role: 'lead' | 'worker', route: PhysicalRoute) => setDraft(value => ({ ...value, [role]: route,
137
- ...(value.cacheKeepalive ? { cacheKeepalive: { ...value.cacheKeepalive, [role]: role === 'lead' ? 'auto' : false } } : {}) }))
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.pair ?? {}) })
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 || !draft.lead || !draft.worker) return
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 { outputTokens, ...rest } = draft
151
- const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0))
152
- const pair = { ...rest, ...(Object.keys(limits).length ? { outputTokens: limits } : {}) }
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, pair }) })
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 current = await fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store' })
158
- if (!current.ok) throw new Error(tr(lang, '配置已提交,但暂时无法确认,请刷新', 'Saved, but not confirmed yet; refresh'))
159
- const confirmed = await current.json() as SettingsState
160
- setState(confirmed); setDraft(confirmed.pair ?? {})
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, '组合已保存并启用。可以在模型菜单中选择 Fusion · 自动。', 'Pair saved and enabled. Choose Fusion · auto in the model menu.'))
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><p>{tr(lang, '选择 Lead 和 Sidekick。Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。', 'Choose a Lead and a Sidekick. The Lead handles requests, plans and reviews; the Sidekick does the delegated work.')}</p>
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
- <fieldset className="fusion-card" disabled={saving}>
176
- <ModelChoice role="Lead" route={draft.lead} catalog={state.catalog} onChange={route => { if (route) chooseModel('lead', route) }} />
177
- <ModelChoice role="Sidekick" route={draft.worker} catalog={state.catalog} onChange={route => { if (route) chooseModel('worker', route) }} />
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={draft.compactor?.route} catalog={state.catalog} onChange={route => setDraft(value => {
182
- const { compactor, ...rest } = value
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
- {draft.compactor && <label>{tr(lang, '输出 Token 上限', 'Output token limit')}<input aria-label={tr(lang, '压缩输出 Token 上限', 'Compaction output token limit')} type="number" min="1" max="128000"
186
- value={draft.compactor.maxOutputTokens} onChange={event => { const maxOutputTokens = Number(event.target.value)
187
- setDraft(value => value.compactor ? { ...value, compactor: { ...value.compactor, maxOutputTokens } } : value) }} /></label>}
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 || !draft.lead || !draft.worker || JSON.stringify(draft) === JSON.stringify(state.pair)} onClick={() => { void save() }}>{saving ? tr(lang, '正在保存…', 'Saving…') : tr(lang, '保存组合', 'Save pair')}</button>
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
- {state.pair && !state.authorized && <p role="status">{tr(lang, '组合需要重新保存后才能使用', 'Save the pair again before use')}{state.reason ? `: ${state.reason}` : ''}</p>}
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} />
@@ -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
- /** One in-flight read per mounted session; collapsed hints still discover new attention states. */
76
- export function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly = false): Snapshot | undefined {
77
- const expandedRef = useRef(expandedTask), attentionRef = useRef(attentionOnly)
78
- expandedRef.current = expandedTask; attentionRef.current = attentionOnly
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, savedLeadRoute } from './host/settings.js'
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
- const configuredProfile = async (): Promise<ResolvedProfile> => {
64
- defaultProfile = undefined
65
- const saved = store.readDocument('settings:profile')
66
- if (!saved && (!config.profilePath || !isAbsolute(config.profilePath))) throw new Error(bi('请先在设置的 Fusion 页面选择 Lead 和 Worker', 'Open Settings → Fusion and choose a Lead and a Sidekick first'))
67
- const raw = saved?.value as Record<string, unknown> | undefined ?? loadJsonObject(config.profilePath!)
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 pair = validatePairChoice(raw, catalog)
70
- const next = saved ? modelProfileFromChoice(pair) : raw
71
- defaultProfile = resolveProfile(next, next.interactionMode === 'model-like' ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes: catalog.groups.flatMap(group =>
72
- group.models.map(model => ({ provider: group.id, model: model.id }))) })
73
- return defaultProfile
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 configuredProfile()
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
- // The catalog entry admits what the configured Lead admits: the Lead is the
111
- // only role that reads user content, while the Worker receives text work orders.
112
- const leadInputModalities = async (): Promise<readonly ModelModality[] | undefined> => {
113
- try {
114
- const saved = store.readDocument('settings:profile')?.value
115
- const lead = savedLeadRoute(saved)
116
- ?? (config.profilePath && isAbsolute(config.profilePath) ? savedLeadRoute(loadJsonObject(config.profilePath)) : undefined)
117
- if (!lead) return undefined
118
- return (await ctx.llm.resolveModelInfo(lead.provider, lead.model)).inputModalities
119
- } catch {
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(leadInputModalities)))
182
+ ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(fusionModels)))
126
183
  ctx.effect(() => installNativeFusionSelection(ctx, {
127
- coordinator: () => runtime, profile: () => defaultProfile,
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
- if (bindings.read(agent.id)?.binding.selected) return { provider: FUSION_PROVIDER, model: FUSION_MODEL }
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 profile: ResolvedProfile | undefined
166
- try { profile = await configuredProfile() } catch { profile = undefined }
167
- return json(res, 200, { checked: CACHE_DEFAULTS_CHECKED, models: cacheView(cachePolicy,
168
- profile ? { lead: profile.profile.lead, worker: profile.profile.worker } : undefined, profile?.profile.cacheKeepalive) })
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
- const saved = store.readDocument('settings:profile')
172
- let profile: ResolvedProfile | undefined, reason: string | undefined, authorized = false
173
- try {
174
- profile = await configuredProfile()
175
- if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile)); authorized = true
176
- } catch (error) { reason = error instanceof Error ? error.message : String(error) }
177
- return json(res, 200, { revision: saved?.revision ?? 0, catalog: await physicalCatalog(),
178
- pair: profile ? choiceFromProfile(profile.profile) : null,
179
- authorized, reason, managedAuthorization: Boolean(config.authorizationPath), policy: profile?.profile.interactionMode ?? 'legacy', actualBilledUsd: null, apiEquivalentUsd: null })
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 configuredProfile()
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 pair = validatePairChoice(body.pair, await physicalCatalog())
256
- const revision = store.writeDocument('settings:profile', Number(body.revision), modelProfileFromChoice(pair))
257
- const profile = await configuredProfile()
258
- await getRuntime(profile)
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
- const saved = store.readDocument('settings:profile')
264
- if (!saved || saved.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
265
- const profile = await configuredProfile()
266
- if (store.readDocument('settings:profile')?.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
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。请在模型选择器中选择 Fusion · 自动。', 'Fusion is not selected in this conversation. Choose Fusion · auto in the model menu.') }
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 configuredProfile()) }
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 })