@sqdr2/dsh-fusion 0.3.2 → 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.
- package/CHANGELOG.md +148 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/RELEASING.md +8 -2
- package/RELEASING.zh.md +7 -2
- package/dshx.yml +3 -0
- package/lib/client.js +292 -74
- package/lib/client.js.map +1 -1
- package/lib/dsh-fusion.js +582 -213
- package/lib/types/client/FusionPermission.d.ts +15 -0
- package/lib/types/client/FusionSettings.d.ts +9 -1
- package/lib/types/client/status-polling.d.ts +6 -2
- package/lib/types/contracts.d.ts +2 -0
- package/lib/types/host/cache-policy.d.ts +16 -6
- package/lib/types/host/native-role-sandbox.d.ts +10 -2
- package/lib/types/host/native-selection.d.ts +15 -5
- package/lib/types/host/pairs.d.ts +47 -0
- package/lib/types/host/settings.d.ts +7 -3
- package/lib/types/identity.d.ts +16 -14
- package/lib/types/status.d.ts +17 -1
- package/package.json +62 -60
- package/src/client/FusionHint.tsx +11 -4
- package/src/client/FusionPermission.tsx +52 -0
- package/src/client/FusionSettings.tsx +109 -30
- package/src/client/index.ts +10 -5
- package/src/client/status-polling.ts +9 -4
- package/src/contracts.ts +2 -0
- package/src/dsh-fusion.ts +130 -54
- package/src/host/cache-policy.ts +18 -6
- package/src/host/native-role-sandbox.ts +18 -6
- package/src/host/native-scopes.ts +2 -2
- package/src/host/native-selection.ts +65 -21
- package/src/host/native-worker-brief.ts +18 -5
- package/src/host/pairs.ts +178 -0
- package/src/host/settings.ts +9 -5
- package/src/host/status.ts +24 -1
- package/src/identity.ts +39 -16
- package/src/status.ts +17 -1
|
@@ -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
|
-
|
|
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<
|
|
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
|
|
137
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
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
|
|
158
|
-
if (!
|
|
159
|
-
const
|
|
160
|
-
setState(
|
|
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, '
|
|
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
|
|
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
|
-
<
|
|
176
|
-
<
|
|
177
|
-
|
|
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={
|
|
182
|
-
const { compactor, ...rest } =
|
|
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
|
-
{
|
|
186
|
-
value={
|
|
187
|
-
|
|
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 || !
|
|
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
|
-
{
|
|
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} />
|
package/src/client/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
|
6
6
|
import { FusionSettings } from './FusionSettings.js'
|
|
7
7
|
import { FusionPanelIcon } from './FusionPanelIcon.js'
|
|
8
8
|
import { FusionHint } from './FusionHint.js'
|
|
9
|
+
import { FusionPermission } from './FusionPermission.js'
|
|
9
10
|
import { FusionTool, fusionControlTools } from './FusionTool.js'
|
|
10
11
|
import { bindLocale } from './i18n.js'
|
|
11
12
|
|
|
@@ -39,12 +40,16 @@ export function apply(ctx: Context): void {
|
|
|
39
40
|
name: 'sidebar.panellist', id: FUSION_PANEL_ID, order: 40, label: () => 'Fusion',
|
|
40
41
|
}, FusionPanelIcon))
|
|
41
42
|
ctx.inject(['modelDirectories'], scope => {
|
|
43
|
+
const sessionProps = (sessionId: Parameters<typeof scope.modelDirectories.directoryFor>[0]) => {
|
|
44
|
+
const directory = scope.modelDirectories.directoryFor(sessionId)
|
|
45
|
+
return { sessionId, directory: directory.store, load: () => { void directory.load().catch(() => undefined) } }
|
|
46
|
+
}
|
|
42
47
|
scope.slots.inject('conversation.input.dock', () => scope.slots.register({
|
|
43
|
-
name: 'conversation.input.dock', id: 'fusion-configuration', order: 20,
|
|
44
|
-
inject: sessionId => {
|
|
45
|
-
const directory = scope.modelDirectories.directoryFor(sessionId)
|
|
46
|
-
return { sessionId, directory: directory.store, load: () => { void directory.load().catch(() => undefined) } }
|
|
47
|
-
},
|
|
48
|
+
name: 'conversation.input.dock', id: 'fusion-configuration', order: 20, inject: sessionProps,
|
|
48
49
|
}, FusionHint))
|
|
50
|
+
// Additive: the native permission control keeps its slot and its selection state.
|
|
51
|
+
scope.slots.inject('conversation.input.left', () => scope.slots.register({
|
|
52
|
+
name: 'conversation.input.left', id: 'fusion-permission', order: 10, inject: sessionProps,
|
|
53
|
+
}, FusionPermission))
|
|
49
54
|
})
|
|
50
55
|
}
|
|
@@ -72,13 +72,18 @@ function subscribe(sessionId: string, listener: () => void, expanded: () => bool
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
/**
|
|
76
|
+
* One in-flight read per mounted session; collapsed hints still discover new attention states.
|
|
77
|
+
* `live` asks for the fast cadence whenever the task is running, for a view whose whole
|
|
78
|
+
* purpose is the current stage (the role and permission of the model working now).
|
|
79
|
+
*/
|
|
80
|
+
export function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly = false, live = false): Snapshot | undefined {
|
|
81
|
+
const expandedRef = useRef(expandedTask), attentionRef = useRef(attentionOnly), liveRef = useRef(live)
|
|
82
|
+
expandedRef.current = expandedTask; attentionRef.current = attentionOnly; liveRef.current = live
|
|
79
83
|
useEffect(() => { if (expandedTask) entries.get(sessionId)?.visibility() }, [sessionId, expandedTask])
|
|
80
84
|
return useSyncExternalStore(
|
|
81
85
|
useCallback(listener => subscribe(sessionId, listener, () => {
|
|
86
|
+
if (liveRef.current) return true
|
|
82
87
|
const task = entries.get(sessionId)?.snapshot?.status?.task
|
|
83
88
|
return expandedRef.current !== undefined && task?.id === expandedRef.current && (!attentionRef.current || task.attention)
|
|
84
89
|
}), [sessionId]),
|
package/src/contracts.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type RunSelection =
|
|
|
40
40
|
| { kind: 'profile'; profileId: string; requestedVersion?: string }
|
|
41
41
|
|
|
42
42
|
export type Role = 'lead' | 'worker'
|
|
43
|
+
/** A Host sandbox mode, spelled as the `sandbox/mode` session event writes it. */
|
|
44
|
+
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
43
45
|
export type Phase =
|
|
44
46
|
| 'READY'
|
|
45
47
|
| 'PLANNING'
|
package/src/dsh-fusion.ts
CHANGED
|
@@ -13,8 +13,10 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-commands'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
15
15
|
import { FusionCatalogAdapter, FUSION_PROVIDER, FUSION_MODEL, installNativeFusionSelection } from './host/native-selection.js'
|
|
16
|
+
import type { FusionModelEntry } from './host/native-selection.js'
|
|
16
17
|
import type { ResolvedProfile } from './profile/resolve.js'
|
|
17
|
-
import { choiceFromProfile, modelProfileFromChoice, validatePairChoice,
|
|
18
|
+
import { choiceFromProfile, modelProfileFromChoice, validatePairChoice, type PairChoice } from './host/settings.js'
|
|
19
|
+
import { MAX_PAIRS, choiceFromStoredProfile, modelOfProfileId, profileIdOf, readPairs, validatePairs, writePairs } from './host/pairs.js'
|
|
18
20
|
import { CachePolicy, cacheView } from './host/cache-policy.js'
|
|
19
21
|
import { CACHE_DEFAULTS_CHECKED } from './host/cache-defaults.js'
|
|
20
22
|
import { loadJsonObject, resolveProfile } from './profile/resolve.js'
|
|
@@ -55,22 +57,71 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
55
57
|
const cachePolicy = new CachePolicy(store)
|
|
56
58
|
backfillHistory(store)
|
|
57
59
|
const budget = new NativeRequestBudget(store, config.authorizationPath)
|
|
58
|
-
let defaultProfile: ResolvedProfile | undefined
|
|
59
60
|
const physicalCatalog = async () => {
|
|
60
61
|
const catalog = await ctx.sessionController.modelCatalog()
|
|
61
62
|
return { ...catalog, groups: catalog.groups.filter(group => !isFusionProvider(group.id)) }
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The combos to resolve: the saved list, or the one profile file a benchmark
|
|
66
|
+
* deployment names. A profile file keeps its legacy contract, so only saved
|
|
67
|
+
* combos are rebuilt from a choice.
|
|
68
|
+
*/
|
|
69
|
+
const comboPlan = (): { revision: number; items: readonly { id: string; name: string; pair: PairChoice; raw?: Record<string, unknown> }[] } => {
|
|
70
|
+
const snapshot = readPairs(store)
|
|
71
|
+
if (snapshot.pairs.length) return { revision: snapshot.revision, items: snapshot.pairs }
|
|
72
|
+
if (config.profilePath && isAbsolute(config.profilePath)) {
|
|
73
|
+
const raw = loadJsonObject(config.profilePath)
|
|
74
|
+
const pair = choiceFromStoredProfile(raw)
|
|
75
|
+
if (pair) return { revision: snapshot.revision, items: [{ id: FUSION_MODEL, name: '自动', pair, raw }] }
|
|
76
|
+
}
|
|
77
|
+
return { revision: snapshot.revision, items: [] }
|
|
78
|
+
}
|
|
79
|
+
interface Combos {
|
|
80
|
+
revision: number
|
|
81
|
+
items: readonly { id: string; name: string; pair: PairChoice }[]
|
|
82
|
+
/** Frozen profile of every usable combo, keyed by the model id it advertises. */
|
|
83
|
+
profiles: Map<string, ResolvedProfile>
|
|
84
|
+
/** Why one saved combo cannot run yet; the other combos stay usable. */
|
|
85
|
+
failures: Map<string, string>
|
|
86
|
+
}
|
|
87
|
+
let combos: Combos | undefined
|
|
88
|
+
const resolveCombos = async (): Promise<Combos> => {
|
|
89
|
+
const plan = comboPlan()
|
|
68
90
|
const catalog = await physicalCatalog()
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
const authorizedRoutes = catalog.groups.flatMap(group => group.models.map(model => ({ provider: group.id, model: model.id })))
|
|
92
|
+
const profiles = new Map<string, ResolvedProfile>(), failures = new Map<string, string>()
|
|
93
|
+
for (const item of plan.items) {
|
|
94
|
+
try {
|
|
95
|
+
const pair = validatePairChoice(item.pair, catalog)
|
|
96
|
+
const next = item.raw ?? modelProfileFromChoice(pair, profileIdOf(item.id))
|
|
97
|
+
profiles.set(item.id, resolveProfile(next, next.interactionMode === 'model-like' ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes }))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// One retired model must not take every other combo down with it.
|
|
100
|
+
failures.set(item.id, error instanceof Error ? error.message : String(error))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
combos = { revision: plan.revision, items: plan.items.map(({ id, name, pair }) => ({ id, name, pair })), profiles, failures }
|
|
104
|
+
return combos
|
|
105
|
+
}
|
|
106
|
+
const knownCombos = async (): Promise<Combos> => combos ?? await resolveCombos()
|
|
107
|
+
// A combo list saved from another process (or before the model catalog was
|
|
108
|
+
// ready at startup) is picked up on first use instead of failing the session.
|
|
109
|
+
let reloading = false
|
|
110
|
+
const profileOf = (model: string): ResolvedProfile | undefined => {
|
|
111
|
+
const found = combos?.profiles.get(model)
|
|
112
|
+
if (!found && !reloading) {
|
|
113
|
+
reloading = true
|
|
114
|
+
void resolveCombos().catch(() => undefined).finally(() => { reloading = false })
|
|
115
|
+
}
|
|
116
|
+
return found
|
|
117
|
+
}
|
|
118
|
+
/** The profile a plain (unselected) request falls back to: the first usable combo. */
|
|
119
|
+
const primaryProfile = async (): Promise<ResolvedProfile> => {
|
|
120
|
+
const state = await knownCombos()
|
|
121
|
+
const profile = state.profiles.values().next().value
|
|
122
|
+
if (profile) return profile
|
|
123
|
+
const [first] = state.failures.values()
|
|
124
|
+
throw new Error(first ?? bi('请先在设置的 Fusion 页面选择 Lead 和 Worker', 'Open Settings → Fusion and choose a Lead and a Sidekick first'))
|
|
74
125
|
}
|
|
75
126
|
const needsBudget = (profile: ResolvedProfile['profile']) => Boolean(config.authorizationPath) || profile.interactionMode !== 'model-like'
|
|
76
127
|
let runtime: FusionCoordinator | undefined
|
|
@@ -82,7 +133,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
82
133
|
if (runtime) return Promise.resolve(runtime)
|
|
83
134
|
if (creating) return creating
|
|
84
135
|
creating = (async () => {
|
|
85
|
-
const profile = frozen ?? await
|
|
136
|
+
const profile = frozen ?? await primaryProfile()
|
|
86
137
|
runtime = new FusionCoordinator(ctx, store, { profile, workerTools: config.workerTools ?? defaultWorkerTools,
|
|
87
138
|
resumeAuthorization: binding => needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account',
|
|
88
139
|
authorizeRequest: (_agent, binding) => {
|
|
@@ -107,28 +158,39 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
107
158
|
})().finally(() => { creating = undefined })
|
|
108
159
|
return creating
|
|
109
160
|
}
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// An unresolvable Lead route (unregistered provider, retired model, broken
|
|
121
|
-
// profile file) cannot verify image support; the catalog entry stays text-only.
|
|
122
|
-
return undefined
|
|
161
|
+
// Each combo's entry admits what its own Lead admits: the Lead is the only
|
|
162
|
+
// role that reads user content, while the Worker receives text work orders.
|
|
163
|
+
// This resolves through the other providers' own model info, never through
|
|
164
|
+
// this adapter, so listing Fusion cannot recurse into itself.
|
|
165
|
+
const fusionModels = async (): Promise<readonly FusionModelEntry[]> => {
|
|
166
|
+
let items: readonly { id: string; name: string; pair: PairChoice }[]
|
|
167
|
+
try { items = readPairs(store).pairs } catch { return [] }
|
|
168
|
+
if (!items.length && config.profilePath && isAbsolute(config.profilePath)) {
|
|
169
|
+
const pair = choiceFromStoredProfile(loadJsonObject(config.profilePath))
|
|
170
|
+
items = pair ? [{ id: FUSION_MODEL, name: '自动', pair }] : []
|
|
123
171
|
}
|
|
172
|
+
return Promise.all(items.map(async item => {
|
|
173
|
+
let modalities: readonly ModelModality[] = []
|
|
174
|
+
try {
|
|
175
|
+
// An unresolvable Lead route (unregistered provider, retired model, broken
|
|
176
|
+
// profile file) cannot verify image support; the entry stays text-only.
|
|
177
|
+
modalities = (await ctx.llm.resolveModelInfo(item.pair.lead.provider, item.pair.lead.model)).inputModalities ?? []
|
|
178
|
+
} catch { modalities = [] }
|
|
179
|
+
return { id: item.id, name: `Fusion · ${item.name}`, inputModalities: modalities }
|
|
180
|
+
}))
|
|
124
181
|
}
|
|
125
|
-
ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(
|
|
182
|
+
ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(fusionModels)))
|
|
126
183
|
ctx.effect(() => installNativeFusionSelection(ctx, {
|
|
127
|
-
coordinator: () => runtime,
|
|
184
|
+
coordinator: () => runtime,
|
|
185
|
+
profile: profileOf,
|
|
186
|
+
// A combo that cannot run yet says why, instead of the generic "configure first".
|
|
187
|
+
reason: model => combos?.failures.get(model)
|
|
188
|
+
?? (combos?.items.length ? bi('该组合已被删除或无法使用,请在模型菜单中选择其他组合。', 'That combo was removed or cannot run; pick another one in the model menu.') : undefined),
|
|
128
189
|
selection: agent => {
|
|
129
190
|
const selection = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
|
|
130
191
|
if (selection?.type === 'model/selection') return selection.data
|
|
131
|
-
|
|
192
|
+
const bound = bindings.read(agent.id)?.binding
|
|
193
|
+
if (bound?.selected) return { provider: FUSION_PROVIDER, model: modelOfProfileId(bound.profile.id) ?? FUSION_MODEL }
|
|
132
194
|
const header = agent.session.requestHeader()
|
|
133
195
|
return header ? { provider: header.config.provider, model: header.config.model } : ctx.agentDefaultModel.currentSelection()
|
|
134
196
|
},
|
|
@@ -162,27 +224,35 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
162
224
|
}
|
|
163
225
|
if (url.searchParams.get('view') === 'catalog') return json(res, 200, await ctx.sessionController.modelCatalog())
|
|
164
226
|
if (url.searchParams.get('view') === 'cache') {
|
|
165
|
-
let
|
|
166
|
-
try {
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
let pairs: { lead: { provider: string; model: string }; worker: { provider: string; model: string }; cacheKeepalive?: PairChoice['cacheKeepalive'] }[] = []
|
|
228
|
+
try { pairs = [...(await knownCombos()).profiles.values()].map(profile => ({ lead: profile.profile.lead,
|
|
229
|
+
worker: profile.profile.worker, cacheKeepalive: profile.profile.cacheKeepalive })) } catch { pairs = [] }
|
|
230
|
+
return json(res, 200, { checked: CACHE_DEFAULTS_CHECKED, models: cacheView(cachePolicy, pairs) })
|
|
169
231
|
}
|
|
170
232
|
if (url.searchParams.get('view') === 'settings') {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
profile =
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
// Resolved fresh: the revision a save sends must match the one shown.
|
|
234
|
+
const state = await resolveCombos()
|
|
235
|
+
const pairs = state.items.map(item => {
|
|
236
|
+
const profile = state.profiles.get(item.id)
|
|
237
|
+
if (!profile) return { id: item.id, name: item.name, pair: item.pair, authorized: false, reason: state.failures.get(item.id) }
|
|
238
|
+
let authorized = false, reason: string | undefined
|
|
239
|
+
try { if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile)); authorized = true }
|
|
240
|
+
catch (error) { reason = error instanceof Error ? error.message : String(error) }
|
|
241
|
+
return { id: item.id, name: item.name, pair: choiceFromProfile(profile.profile), authorized, ...(reason ? { reason } : {}) }
|
|
242
|
+
})
|
|
243
|
+
const blocked = pairs.find(pair => !pair.authorized)
|
|
244
|
+
return json(res, 200, { revision: state.revision, catalog: await physicalCatalog(), pairs, maxPairs: MAX_PAIRS,
|
|
245
|
+
// The first combo is what the single-pair page and older tabs read.
|
|
246
|
+
pair: pairs[0]?.pair ?? null, authorized: pairs.length > 0 && !blocked,
|
|
247
|
+
...(blocked?.reason ? { reason: blocked.reason } : {}),
|
|
248
|
+
managedAuthorization: Boolean(config.authorizationPath),
|
|
249
|
+
policy: state.profiles.values().next().value?.profile.interactionMode ?? 'model-like', actualBilledUsd: null, apiEquivalentUsd: null })
|
|
180
250
|
}
|
|
181
251
|
const sessionId = url.searchParams.get('sessionId') ?? ''
|
|
182
252
|
const binding = sessionId && bindings.read(sessionId)?.binding
|
|
183
253
|
let available = false, reason: string | undefined
|
|
184
254
|
try {
|
|
185
|
-
const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await
|
|
255
|
+
const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await primaryProfile()
|
|
186
256
|
const controller = await getRuntime(profile)
|
|
187
257
|
controller.transport.assertAvailable()
|
|
188
258
|
if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile))
|
|
@@ -252,19 +322,25 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
252
322
|
}
|
|
253
323
|
if (body.action === 'configure') {
|
|
254
324
|
if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) throw new Error(bi('配置版本无效,请刷新后重试', 'Invalid settings revision; refresh and retry'))
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
325
|
+
const catalog = await physicalCatalog(), current = readPairs(store)
|
|
326
|
+
// A tab from before combos existed sends one pair; keep the other combos
|
|
327
|
+
// and let it edit only the first one it can see.
|
|
328
|
+
const proposed = Array.isArray(body.pairs) ? body.pairs
|
|
329
|
+
: [{ ...(current.pairs[0] ?? { id: FUSION_MODEL, name: '自动' }), pair: body.pair }, ...current.pairs.slice(1)]
|
|
330
|
+
const pairs = validatePairs(proposed, catalog, current.pairs)
|
|
331
|
+
const revision = writePairs(store, Number(body.revision), pairs)
|
|
332
|
+
const state = await resolveCombos()
|
|
333
|
+
const profile = state.profiles.values().next().value
|
|
334
|
+
if (profile) await getRuntime(profile)
|
|
259
335
|
return json(res, 200, { ok: true, revision })
|
|
260
336
|
}
|
|
261
337
|
if (body.action === 'authorize') {
|
|
262
338
|
if (config.authorizationPath) throw new Error(bi('运行额度由外部授权文件管理,不能在此覆盖', 'Run limits are managed by an external authorization file and cannot be overridden here'))
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
if (store.
|
|
267
|
-
const auth = authorizeConfiguredPair(store, profileRoutes(profile.profile), body.limits)
|
|
339
|
+
if (readPairs(store).revision !== body.revision) throw new Error(bi('组合列表已变化,请刷新后再启用额度', 'The combo list changed; refresh before enabling limits'))
|
|
340
|
+
// One authorization must cover every combo the menu can offer.
|
|
341
|
+
const state = await resolveCombos()
|
|
342
|
+
if (readPairs(store).revision !== body.revision) throw new Error(bi('组合列表已变化,请刷新后再启用额度', 'The combo list changed; refresh before enabling limits'))
|
|
343
|
+
const auth = authorizeConfiguredPair(store, [...state.profiles.values()].flatMap(profile => profileRoutes(profile.profile)), body.limits)
|
|
268
344
|
return json(res, 200, { ok: true, expiresAt: auth.expiresAt })
|
|
269
345
|
}
|
|
270
346
|
throw new Error(bi('请使用原模型菜单选择 Fusion 或切换普通模型', 'Use the model menu to select Fusion or another model'))
|
|
@@ -280,7 +356,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
280
356
|
const [action = 'status', ...note] = invocation.rawInput.trim().split(/\s+/)
|
|
281
357
|
try {
|
|
282
358
|
const binding = bindings.read(invocation.agent.id)?.binding
|
|
283
|
-
if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion
|
|
359
|
+
if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion。请在模型选择器中选择一个 Fusion 组合。', 'Fusion is not selected in this conversation. Choose a Fusion combo in the model menu.') }
|
|
284
360
|
if (action === 'status' || action === '') {
|
|
285
361
|
const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
|
|
286
362
|
const state = store.load(binding.taskId)!
|
|
@@ -312,7 +388,7 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
|
312
388
|
} }))
|
|
313
389
|
ctx.effect(() => async () => { if (creating) await creating.catch(() => undefined); if (runtime) await runtime.close(); store.close() })
|
|
314
390
|
// Warm only configuration/scopes; initialization performs no inference.
|
|
315
|
-
try { await getRuntime(await
|
|
391
|
+
try { await getRuntime(await primaryProfile()) }
|
|
316
392
|
catch {
|
|
317
393
|
const selected = bindings.selected()[0]
|
|
318
394
|
if (selected) await getRuntime({ profile: selected.profile, prompts: selected.prompts })
|
package/src/host/cache-policy.ts
CHANGED
|
@@ -148,12 +148,24 @@ export class CachePolicy {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
type Route = { provider: string; model: string }
|
|
151
|
-
/**
|
|
152
|
-
export
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|