fix(web): preserve provider credential retry checkpoint

This commit is contained in:
Yichen Jiang
2026-08-06 13:15:09 +08:00
parent f330b6ae79
commit 099b903ac6
5 changed files with 44 additions and 11 deletions

View File

@@ -133,9 +133,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// A settings success becomes the next retry baseline immediately. If the
// following credential write fails, retry sends only the credential instead
// of replaying the already-committed settings write with a stale revision.
// A settings success advances both retry baselines immediately. Keeping the
// derived fields in the draft prevents a pushed namespace refresh from
// turning them into deletions when the following credential write is retried.
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
() => getPath(namespace.user, settingsPath),
)
@@ -215,6 +215,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
setExpectedRevision(response.result.value.revision)
setDraft(next)
}
if (normalizedKey.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })

View File

@@ -10,7 +10,7 @@ export const en = {
remove: 'Delete',
removeProvider: 'Delete {provider}',
deleteTitle: 'Delete {provider}?',
deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.',
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
deleteConfirm: 'Delete {provider}',
deleting: 'Deleting {provider}…',
@@ -75,7 +75,7 @@ export const zh: typeof en = {
remove: '删除',
removeProvider: '删除 {provider}',
deleteTitle: '删除 {provider}',
deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。',
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
deleteConfirm: '删除 {provider}',
deleting: '正在删除 {provider}…',

View File

@@ -816,7 +816,7 @@ describe('ModelsSection', () => {
expect(set).not.toHaveBeenCalled()
})
it('retries only the credential after settings already committed', async () => {
it('retries only the credential after refreshed settings already committed', async () => {
const committed = wireNamespaces()[2]!
const afterSettings: SettingsNamespaceView = {
...committed,
@@ -834,7 +834,7 @@ describe('ModelsSection', () => {
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
await mountSection({ mutate, set })
const { face, controller } = await mountSection({ mutate, set })
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
@@ -842,6 +842,13 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await screen.findByText('credential store unavailable')
expect(mutate).toHaveBeenCalledOnce()
face.settings.describe.mockResolvedValue(ok({
writable: true,
hasDocument: false,
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
}))
await act(async () => { await controller.load() })
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
expect(mutate).toHaveBeenCalledOnce()