@sqdr2/dsh-fusion 0.3.0 → 0.3.3

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.
@@ -7,14 +7,19 @@ import { settingsChanged } from './settings-events.js'
7
7
  import { tr, useLang } from './i18n.js'
8
8
  import { FUSION_API_PATH } from '../identity.js'
9
9
 
10
+ /** One saved combo as the page shows it: its id, its label and its pair. */
11
+ export interface SavedPairView { id: string; name: string; pair: PairChoice; authorized: boolean; reason?: string }
10
12
  interface SettingsState {
11
13
  revision: number
12
14
  catalog: SettingsCatalog
13
- pair: PairChoice | null
15
+ pairs: SavedPairView[]
16
+ maxPairs: number
14
17
  authorized: boolean
15
18
  managedAuthorization?: boolean
16
19
  reason?: string
17
20
  }
21
+ /** A row being edited. `id` is absent until the list is saved for the first time. */
22
+ interface DraftCombo { key: string; id?: string; name: string; pair: Partial<PairChoice> }
18
23
  const styles = `
19
24
  .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
25
  .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 +41,27 @@ const styles = `
36
41
  .fusion-settings details{font-size:13px;line-height:21px}
37
42
  .fusion-settings summary{cursor:pointer;font-weight:500}
38
43
  .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}
44
+ .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
45
  .fusion-settings .fusion-consent{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}
46
+ .fusion-combos{display:flex;flex-direction:column;gap:8px}
47
+ .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}
48
+ .fusion-settings .fusion-combo:hover{background-color:var(--dsw-alias-interactive-bg-hover)}
49
+ .fusion-settings .fusion-combo[data-active=true]{border-color:var(--dsw-alias-brand-primary);background-color:var(--dsw-alias-interactive-bg-hover-accent)}
50
+ .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}
51
+ .fusion-settings .fusion-combo-main:hover{background:none}
52
+ .fusion-settings .fusion-combo :is(.fusion-combo-main,.fusion-combo-actions):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
53
+ .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}
54
+ .fusion-settings .fusion-combo:hover .fusion-combo-check{border-color:var(--dsw-alias-label-tertiary)}
55
+ .fusion-settings .fusion-combo[data-active=true] .fusion-combo-check{border-color:var(--dsw-alias-brand-primary)}
56
+ .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)}
57
+ .fusion-settings .fusion-combo-text{display:flex;flex-direction:column;gap:2px;min-width:0}
58
+ .fusion-settings .fusion-combo-name{font-weight:600;overflow-wrap:anywhere}
59
+ .fusion-settings .fusion-combo-routes{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}
60
+ .fusion-settings .fusion-combo-actions{flex:none;align-self:stretch;display:flex;align-items:center;padding:0 14px;border:0;border-left:1px solid var(--dsw-alias-border-l1);border-radius:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;transition:color .15s ease,background-color .15s ease}
61
+ .fusion-settings .fusion-combo-actions:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background-color:var(--dsw-alias-interactive-bg-hover-danger)}
62
+ .fusion-settings .fusion-combo-actions:disabled{opacity:.4;cursor:default}
63
+ .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}
64
+ .fusion-settings .fusion-combo-add:hover:not(:disabled){background-color:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
41
65
  .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
66
  .fusion-cache h3{margin:0}
43
67
  .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}
@@ -126,76 +150,131 @@ export function ModelChoice({ role, route, catalog, onChange }: {
126
150
  </fieldset>
127
151
  }
128
152
 
153
+ let draftKeys = 0
154
+ const toDraft = (pairs: readonly SavedPairView[]): DraftCombo[] => pairs.map(({ id, name, pair }) => ({ key: id, id, name, pair }))
155
+
129
156
  export function FusionSettings() {
130
157
  const lang = useLang()
131
158
  const [state, setState] = useState<SettingsState>()
132
- const [draft, setDraft] = useState<Partial<PairChoice>>({})
159
+ const [draft, setDraft] = useState<DraftCombo[]>([])
160
+ const [active, setActive] = useState(0)
133
161
  const [error, setError] = useState<string>()
134
162
  const [notice, setNotice] = useState<string>()
135
163
  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 } } : {}) }))
164
+ const current = draft[Math.min(active, draft.length - 1)]
165
+ const routeName = (route?: PhysicalRoute) => route
166
+ ? state?.catalog.groups.find(group => group.id === route.provider)?.models.find(model => model.id === route.model)?.name
167
+ ?? `${route.provider}/${route.model}` : '—'
168
+ const update = (change: (combo: DraftCombo) => DraftCombo) => setDraft(value => value.map((combo, index) =>
169
+ combo.key === current?.key ? change(combo) : combo))
170
+ const chooseModel = (role: 'lead' | 'worker', route: PhysicalRoute) => update(combo => ({ ...combo, pair: { ...combo.pair, [role]: route,
171
+ ...(combo.pair.cacheKeepalive ? { cacheKeepalive: { ...combo.pair.cacheKeepalive, [role]: role === 'lead' ? 'auto' : false } } : {}) } }))
138
172
  useEffect(() => {
139
173
  const abort = new AbortController()
140
174
  void fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
141
175
  .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 ?? {}) })
176
+ .then(value => { setState(value); setDraft(toDraft(value.pairs)); setActive(0) })
143
177
  .catch(error => { if (!abort.signal.aborted) setError(String(error)) })
144
178
  return () => abort.abort()
145
179
  }, [])
180
+ const addCombo = () => {
181
+ draftKeys += 1
182
+ setDraft(value => [...value, { key: `new-${draftKeys}`, name: tr(lang, `组合 ${value.length + 1}`, `Combo ${value.length + 1}`), pair: {} }])
183
+ setActive(draft.length)
184
+ }
185
+ const removeCombo = (index: number) => {
186
+ setDraft(value => value.filter((_, position) => position !== index))
187
+ setActive(Math.max(0, Math.min(active > index ? active - 1 : active, draft.length - 2)))
188
+ }
146
189
  const save = async () => {
147
- if (!state || !draft.lead || !draft.worker) return
190
+ if (!state) return
191
+ const invalid = draft.findIndex(combo => !combo.pair.lead || !combo.pair.worker || !combo.name.trim())
192
+ 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 }
148
193
  setSaving(true); setError(undefined); setNotice(undefined)
149
194
  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 } : {}) }
195
+ const pairs = draft.map(({ id, name, pair }) => {
196
+ const { outputTokens, ...rest } = pair
197
+ const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0))
198
+ return { ...(id ? { id } : {}), name: name.trim(), pair: { ...rest, ...(Object.keys(limits).length ? { outputTokens: limits } : {}) } }
199
+ })
153
200
  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 }) })
201
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'configure', revision: state.revision, pairs }) })
155
202
  const result = await response.json() as { revision: number; error?: string }
156
203
  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 ?? {})
204
+ const confirmed = await fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store' })
205
+ if (!confirmed.ok) throw new Error(tr(lang, '配置已提交,但暂时无法确认,请刷新', 'Saved, but not confirmed yet; refresh'))
206
+ const next = await confirmed.json() as SettingsState
207
+ setState(next); setDraft(toDraft(next.pairs)); setActive(0)
161
208
  settingsChanged()
162
- setNotice(tr(lang, '组合已保存并启用。可以在模型菜单中选择 Fusion · 自动。', 'Pair saved and enabled. Choose Fusion · auto in the model menu.'))
209
+ 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
210
  } catch (error) { setError(error instanceof Error ? error.message : String(error)) }
164
211
  finally { setSaving(false) }
165
212
  }
213
+ const dirty = state ? JSON.stringify(draft.map(({ id, name, pair }) => ({ id, name, pair })))
214
+ !== JSON.stringify(state.pairs.map(({ id, name, pair }) => ({ id, name, pair }))) : false
215
+ const blocked = state?.pairs.filter(pair => !pair.authorized) ?? []
166
216
  return <div className="fusion-page">
167
217
  <style>{styles}</style>
168
218
  <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>
219
+ <header data-window-drag><h2>Fusion</h2>
220
+ <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>
221
+ <p>{tr(lang, 'Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。', 'The Lead handles requests, plans and reviews; the Sidekick does the delegated work.')}</p>
170
222
  <p><b>{tr(lang, '搭配建议', 'Pairing tip')}</b>: {tr(lang, 'Fusion 省钱靠的是 Sidekick 比 Lead 便宜得多。选一个强的前沿模型当 Lead,再选一个价格低很多(最好 5 倍以上)的模型当 Sidekick;两者价格越接近,越省不了钱。',
171
223
  '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
224
  {error && <p role="alert">{error}</p>}
173
225
  {!state && !error && <p role="status">{tr(lang, '正在读取模型…', 'Loading models…')}</p>}
174
226
  {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) }} />
227
+ <section className="fusion-card" aria-label={tr(lang, '组合列表', 'Combo list')}>
228
+ <div className="fusion-combos">
229
+ {draft.map((combo, index) => {
230
+ const selected = combo.key === current?.key
231
+ const label = combo.name.trim() || tr(lang, '未命名组合', 'Untitled combo')
232
+ // The whole row is the selection target: the button spans the row up to the Delete
233
+ // button, so the padding and the empty space beside the text select the combo too.
234
+ return <div className="fusion-combo" key={combo.key} data-active={selected}>
235
+ <button type="button" className="fusion-combo-main" aria-pressed={selected}
236
+ onClick={() => setActive(index)}>
237
+ <span className="fusion-combo-check" aria-hidden="true" />
238
+ <span className="fusion-combo-text">
239
+ <span className="fusion-combo-name">{label}</span>
240
+ <span className="fusion-combo-routes">{`${routeName(combo.pair.lead)} → ${routeName(combo.pair.worker)}`}</span>
241
+ </span>
242
+ </button>
243
+ <button type="button" className="fusion-combo-actions" disabled={saving || draft.length <= 1}
244
+ aria-label={tr(lang, `删除组合 ${label}`, `Delete combo ${label}`)}
245
+ onClick={event => { event.stopPropagation(); removeCombo(index) }}>{tr(lang, '删除', 'Delete')}</button>
246
+ </div>
247
+ })}
248
+ {!draft.length && <p>{tr(lang, '还没有组合。新增一个,选择 Lead 和 Sidekick 后保存。', 'No combos yet. Add one, choose a Lead and a Sidekick, then save.')}</p>}
249
+ </div>
250
+ <button type="button" className="fusion-combo-add" disabled={saving || draft.length >= state.maxPairs} onClick={addCombo}>{tr(lang, '+ 新增组合', '+ Add combo')}</button>
251
+ <p>{tr(lang, `最多保存 ${state.maxPairs} 个组合;模型菜单中的名称是「Fusion · 组合名称」。`, `Up to ${state.maxPairs} combos; the model menu shows them as “Fusion · name”.`)}</p>
252
+ </section>
253
+ {current && <fieldset className="fusion-card" disabled={saving}>
254
+ <label>{tr(lang, '名称', 'Name')}<input type="text" maxLength={40} aria-label={tr(lang, '组合名称', 'Combo name')}
255
+ value={current.name} onChange={event => update(combo => ({ ...combo, name: event.target.value }))} /></label>
256
+ <ModelChoice role="Lead" route={current.pair.lead} catalog={state.catalog} onChange={route => { if (route) chooseModel('lead', route) }} />
257
+ <ModelChoice role="Sidekick" route={current.pair.worker} catalog={state.catalog} onChange={route => { if (route) chooseModel('worker', route) }} />
178
258
  <details><summary>{tr(lang, '上下文压缩模型 · 可选', 'Compaction model · optional')}</summary>
179
259
  <div className="fusion-limits">
180
260
  <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
261
+ <ModelChoice role="compactor" route={current.pair.compactor?.route} catalog={state.catalog} onChange={route => update(combo => {
262
+ const { compactor, ...rest } = combo.pair
263
+ return { ...combo, pair: route ? { ...rest, compactor: { route, maxOutputTokens: compactor?.maxOutputTokens ?? 8192 } } : rest }
184
264
  })} />
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>
265
+ {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"
266
+ value={current.pair.compactor.maxOutputTokens} onChange={event => { const maxOutputTokens = Number(event.target.value)
267
+ update(combo => combo.pair.compactor ? { ...combo, pair: { ...combo.pair, compactor: { ...combo.pair.compactor, maxOutputTokens } } } : combo) }} /></label>}
189
268
  </div>
190
269
  </details>
191
- </fieldset>
270
+ </fieldset>}
192
271
  <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>
272
+ <button disabled={saving || !dirty || !draft.length} onClick={() => { void save() }}>{saving ? tr(lang, '正在保存…', 'Saving…') : tr(lang, '保存组合', 'Save combos')}</button>
194
273
  </div>
195
274
  {notice && <p role="status">{notice}</p>}
196
275
  <p>{tr(lang, '输出参数跟随原生模型设置,Fusion 不额外限制请求次数或任务轮数。', 'Output settings follow the native model settings; Fusion adds no request or round limits.')}</p>
197
276
  <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>}
277
+ {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
278
  {state.managedAuthorization && <p>{tr(lang, '运行额度由外部授权配置管理。', 'Run limits are managed by an external authorization file.')}</p>}
200
279
  </>}
201
280
  <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 })
@@ -148,12 +148,24 @@ export class CachePolicy {
148
148
  }
149
149
 
150
150
  type Route = { provider: string; model: string }
151
- /** One row of the settings page: what applies, why, what the defaults say, and what was observed. */
152
- export function cacheView(policy: CachePolicy, pair?: { lead: Route; worker: Route }, pairChoice?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }) {
153
- const same = (a: Route | undefined, provider: string, model: string) => a?.provider === provider && a.model === model
154
- return policy.routes(pair ? [pair.lead, pair.worker] : []).map(({ provider, model }) => {
155
- const role: Role | undefined = same(pair?.lead, provider, model) ? 'lead' : same(pair?.worker, provider, model) ? 'worker' : undefined
156
- const effective = policy.resolve(provider, model, role ?? 'lead', role ? pairChoice?.[role] : undefined)
151
+ /** A saved combo's routes, plus its optional per-role keepalive choice. */
152
+ export interface CachePairRoutes {
153
+ readonly lead: Route
154
+ readonly worker: Route
155
+ readonly cacheKeepalive?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }
156
+ }
157
+ /**
158
+ * One row of the settings page: what applies, why, what the defaults say, and
159
+ * what was observed. Every saved combo contributes its routes, so a second
160
+ * combo's models are listed and labelled even before a session uses them.
161
+ */
162
+ export function cacheView(policy: CachePolicy, pair?: CachePairRoutes | readonly CachePairRoutes[], pairChoice?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }) {
163
+ const pairs: readonly CachePairRoutes[] = Array.isArray(pair) ? pair : pair ? [pair as CachePairRoutes] : []
164
+ const same = (role: 'lead' | 'worker', provider: string, model: string) => pairs.some(item => item[role].provider === provider && item[role].model === model)
165
+ const choiceOf = (role: 'lead' | 'worker') => pairs.find(item => item.cacheKeepalive)?.['cacheKeepalive']?.[role] ?? pairChoice?.[role]
166
+ return policy.routes(pairs.flatMap(item => [item.lead, item.worker])).map(({ provider, model }) => {
167
+ const role: Role | undefined = same('lead', provider, model) ? 'lead' : same('worker', provider, model) ? 'worker' : undefined
168
+ const effective = policy.resolve(provider, model, role ?? 'lead', role ? choiceOf(role) : undefined)
157
169
  const stats = policy.stats(provider, model), { family, route } = effective.defaults
158
170
  return { provider, model, role,
159
171
  mode: effective.mode, intervalSeconds: effective.intervalSeconds, modeSource: effective.modeSource, intervalSource: effective.intervalSource,