fix(models): wait for current snapshot when retrying setup

This commit is contained in:
Tianyi Cui committed 2026-10-05 13:19:22 +08:00
1 parent bd20fc56bb
commit c880291a0b
5 files changed
+56 -6

No files matched your search

@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package:
en: 4f46d6c1b70b2456
zh: 9d2affd4a6b5d3a6
en: db5aaf7f702e1f0c
zh: 409a78c14fb2b6e3
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys:
en: c2f2a5c06ef20d5b
zh: 49a7eb0b66b4ed53
+1 -1
View File
@@ -35,7 +35,7 @@ Open the Models page from the Settings navigation to see every configured provid
A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic.
After Apply closes an editor, its saved status appears when the current provider, settings, and credential snapshot is ready. Opening any row's editor, adding a provider, and opening a removal confirmation wait for that refresh. A newly opened Models section waits for its first ready snapshot before mounting editors. A background refresh preserves an open draft while its editor remains mounted; a failed page load or provider removal can close it.
After Apply closes an editor, its saved status appears when the current provider, settings, and credential snapshot is ready. Opening any row's editor, adding a provider, and opening a removal confirmation wait for that refresh. A newly opened Models section, or one retrying after a page-load failure, waits for a ready snapshot before mounting editors. A background refresh preserves an open draft while its editor remains mounted; a failed page load or provider removal can close it.
Host configuration `credentialOnboarding` defaults to `true`. The Electron preload marker suppresses automatic credential onboarding and the Web welcome notice; Models settings and explicit API-key editing remain available. The [account plugin](../ui-settings-account/README.md#desktop-onboarding) owns the Desktop introduction. Other native shells can disable only the credential step with `credentialOnboarding: false`. Host publishes this public boolean through `webserver/index-inject`, and Client validates it before registering dialogs. It is page initialization data, not a durable completion marker.
@@ -35,7 +35,7 @@ kind: "package-reference"
存在已存储目录错误的提供商仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
应用设置并关闭编辑器后,当前提供商、settings 与凭据快照就绪时才显示保存状态。打开任一行的编辑器、添加提供商和打开移除确认都会等待该刷新。新打开的 Models 区域会等到首个 ready 快照后才挂载编辑器。后台刷新期间,只要编辑器仍挂载,已打开的草稿就会保留用户的修改;页面加载失败或提供商被移除可能关闭编辑器。
应用设置并关闭编辑器后,当前提供商、settings 与凭据快照就绪时才显示保存状态。打开任一行的编辑器、添加提供商和打开移除确认都会等待该刷新。新打开的 Models 区域或页面加载失败后的重试会等到 ready 快照后才挂载编辑器。后台刷新期间,只要编辑器仍挂载,已打开的草稿就会保留用户的修改;页面加载失败或提供商被移除可能关闭编辑器。
Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会抑制自动凭证引导和 Web 欢迎须知;模型设置页与显式 API Key 编辑仍然可用。[账号插件](../ui-settings-account/README.zh.md#desktop-onboarding)负责 Desktop 引导。其他原生壳可以通过 `credentialOnboarding: false` 仅禁用凭证步骤。Host 通过 `webserver/index-inject` 发布这个公开的布尔值,Client 在注册弹窗前校验它。它是页面初始化数据,不是持久化的完成标记。
@@ -234,9 +234,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const snapshot = injected.useSnapshot(value => value)
const state = { ...snapshot, rows: snapshot.rows.map(row => row.entry.provider === 'deepseek-account'
? { ...row, entry: { ...row.entry, displayName: t('deepSeekAccount') } } : row) }
/** A new surface waits for a current snapshot; later refreshes preserve its drafts. */
/** A new or failed surface waits for a current snapshot; background refreshes preserve its drafts. */
const [hasReadySnapshot, setHasReadySnapshot] = useState(state.status === 'ready')
if (state.status === 'ready' && !hasReadySnapshot) setHasReadySnapshot(true)
if (state.status === 'error' && hasReadySnapshot) setHasReadySnapshot(false)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [addOpen, setAddOpen] = useState(false)
const [addMode, setAddMode] = useState<AddMode>('catalog')
@@ -192,7 +192,7 @@ function scriptedFace(overrides: {
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(remoteOk(undefined)))
const face = {
llm: {
listProviders: vi.fn(() => Promise.resolve(remoteOk([
listProviders: vi.fn((): ReturnType<PageContext['remote']['llm']['listProviders']> => Promise.resolve(remoteOk([
{ id: 'deepseek-official', name: 'DeepSeek' },
{ id: 'openai', name: 'openai' },
]))),
@@ -374,6 +374,55 @@ describe('ModelsSection', () => {
await screen.findByRole('status')
})
it('waits for the current snapshot before remounting setup after a failed page load', async () => {
const { controller, face, mirror, mutate } = await mountFirstRun()
const oldKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(oldKey, { target: { value: 'sk-discarded-on-error' } })
face.llm.listProviders.mockResolvedValueOnce(remoteFail('directory unavailable', 'gateway/internal'))
await act(async () => { await controller.load() })
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
expect(screen.getByText(`${en.loadFailed}: directory unavailable`)).toBeTruthy()
const before = controller.store.getSnapshot().namespaces.get('llm-deepseek')!
mirror.acceptView({
...before,
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://recovered.example', models: DEFAULT_DEEPSEEK_MODELS },
user: { baseURL: 'https://recovered.example' },
revision: 2,
})
const read = Promise.withResolvers<RemoteResult<Record<string, CredentialInfo>>>()
const started = Promise.withResolvers<undefined>()
const describe = face.credentials.describe.getMockImplementation()!
face.credentials.describe.mockImplementation((refs) => {
if (refs.length > 1) { started.resolve(undefined); return read.promise }
return describe(refs)
})
const load = vi.spyOn(controller, 'load')
try {
fireEvent.click(screen.getByRole('button', { name: en.retry }))
await act(async () => { await started.promise })
expect(load).toHaveBeenCalledOnce()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'loading' })
expect(controller.store.getSnapshot().namespaces.get('llm-deepseek')?.revision).toBe(0)
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
} finally {
await act(async () => { read.resolve(remoteOk({})); await load.mock.results[0]?.value })
load.mockRestore()
}
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(key).not.toBe(oldKey)
expect(key.value).toBe('')
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://recovered.example')
fireEvent.change(url, { target: { value: 'https://after-retry.example' } })
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(mutate).toHaveBeenCalledWith(
'llm-deepseek', [{ op: 'set', path: ['baseURL'], value: 'https://after-retry.example' }], 2,
) })
await screen.findByRole('status')
})
it('preserves a mounted setup draft while its current refresh is pending', async () => {
const { controller, face } = await mountFirstRun()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)