fix(web): restore the default model catalog without a reload

Restore defaults dropped the user's `models` override from the draft but the
rows kept showing it, so the catalog only looked restored after closing and
reopening the card.

The inherited rows were read from the namespace's effective value, which
still carries the stored override until the unset is applied — so dropping
the override echoed it straight back. They now come from the layer beneath
the user's: what the composition entry pinned, or else the schema default
that resolution would supply.
This commit is contained in:
Yichen Jiang
2026-07-31 14:43:17 +08:00
parent 1dbf70cb8a
commit ef996473a7
2 changed files with 71 additions and 2 deletions

View File

@@ -239,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const keyLocked = keyState?.writable === false
/**
* The catalog beneath the user layer: what the composition entry pinned, or
* else the schema default that `resolve` would supply. The effective value
* cannot answer this — it still carries the stored override until the unset
* is applied, so reading it would echo that override straight back the
* moment reset drops it, leaving the rows unchanged until a reload.
*/
const inheritedModels = (): unknown => {
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
}
/**
* The curated fields of one known adapter family. Taking the narrowed
* family as a parameter is what makes `EFFORT_FIELD` total here: an
@@ -248,7 +260,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const effortField = EFFORT_FIELD[family]
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models']))
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
return (
<>

View File

@@ -41,7 +41,22 @@ const DeepSeekConfig = Schema.object({
name: Schema.string(),
description: Schema.string(),
contextWindow: Schema.number().step(1).min(1),
})),
// The adapter declares its catalog as a schema default rather than a
// composition entry, which is what the restore-defaults path has to read.
})).default([
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '',
contextWindow: 1_000_000,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '',
contextWindow: 1_000_000,
},
]),
})
const DEFAULT_DEEPSEEK_MODELS = [
@@ -420,6 +435,48 @@ describe('ModelsSection', () => {
expect(mutate).not.toHaveBeenCalled()
})
it.each([
['the schema default', undefined],
['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
// The regression: reset read the EFFECTIVE value, which still carries the
// stored override until the unset is applied — so the rows did not change
// and the catalog only looked restored after reopening the card.
const { face } = scriptedFace()
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
const overridden: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { ...stored, defaultContextWindow: 1_000_000 },
...base === undefined ? {} : { base },
user: stored,
applies: 'live',
secrets: [],
revision: 0,
}
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
provider="deepseek-official"
displayName="DeepSeek"
namespace={overridden}
settingsPath={[]}
api={face as never}
t={t}
readOnly={false}
onClose={() => {}}
/>)
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(['user-only-model'])
fireEvent.click(screen.getByText(en.resetModels))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
})
it('renders malformed draft fallbacks without inventing catalog values', () => {
render(<DeepSeekModelsEditor
models={[{}]}