fix(models): gate stale provider actions during refresh

This commit is contained in:
Tianyi Cui committed 2026-10-05 13:09:14 +08:00
1 parent 1fb43c6f78
commit bd20fc56bb
7 files changed
+216 -13

No files matched your search

@@ -14,9 +14,9 @@
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn" [disabled]: 编辑
- button "删除 minimax-cn": 删除
- button "删除 minimax-cn" [disabled]: 删除
- listitem:
- text: Acme 网关 自定义
- button "编辑 Acme 网关 (acme-gateway)" [disabled]: 编辑
- button "删除 Acme 网关 (acme-gateway)": 删除
- button "添加模型提供商"
- button "删除 Acme 网关 (acme-gateway)" [disabled]: 删除
- button "添加模型提供商" [disabled]
+37 -3
View File
@@ -48,6 +48,32 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
await page.addInitScript(() => {
const events = { received: 0, handled: 0 }
Reflect.set(window, '__DSH_MODELS_ADAPTER_EVENTS__', events)
const NativeWebSocket = window.WebSocket
window.WebSocket = class extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols)
this.addEventListener('message', (event: MessageEvent<unknown>) => {
if (typeof event.data !== 'string') return
const frame: unknown = JSON.parse(event.data)
if (typeof frame !== 'object' || frame === null || !('type' in frame) || frame.type !== 'item' || !('value' in frame)) return
const value = frame.value
if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'emit' || !('event' in value) || value.event !== 'llm/adapters-updated') return
const received = ++events.received
// The Gateway starts notification handlers before this acknowledgement task.
const channel = new MessageChannel()
channel.port1.onmessage = () => {
events.handled = Math.max(events.handled, received)
channel.port1.close()
channel.port2.close()
}
channel.port2.postMessage(undefined)
})
}
}
})
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -368,12 +394,20 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
inputModalities: ['image'],
})
await firstStarted.promise
const obsolete = [...reads]
const receivedBefore = await page.evaluate(() => {
const events: unknown = Reflect.get(window, '__DSH_MODELS_ADAPTER_EVENTS__')
if (typeof events !== 'object' || events === null || !('received' in events) || typeof events.received !== 'number') throw new Error('Missing adapter event observer')
return events.received
})
nextStarted = Promise.withResolvers<undefined>()
const successorHandled = page.waitForFunction((received) => {
const events: unknown = Reflect.get(window, '__DSH_MODELS_ADAPTER_EVENTS__')
return typeof events === 'object' && events !== null && 'handled' in events && typeof events.handled === 'number' && events.handled > received
}, receivedBefore)
scaffold.ctx.emit('llm/adapters-updated')
await successorHandled
await nextStarted.promise
const latest = reads.at(-1)
if (latest === undefined) throw new Error('Missing current credentials read')
const obsolete = reads.slice(0, -1)
const oldResponses = obsolete.map(read => page.waitForResponse(response => response.request() === read.request))
for (const read of obsolete) read.release()
await Promise.all(oldResponses.map(async (response) => { await (await response).finished() }))
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package:
en: d231b7df6eb8fc75
zh: 10c4e12284daf12b
en: 4f46d6c1b70b2456
zh: 9d2affd4a6b5d3a6
/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. Editing another row waits for that refresh. 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 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.
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 与凭据快照就绪时才显示保存状态。打开其他行的编辑器会等待该刷新。后台刷新期间,只要编辑器仍挂载,已打开的草稿就会保留用户的修改;页面加载失败或提供商被移除可能关闭编辑器。
应用设置并关闭编辑器后,当前提供商、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,6 +234,9 @@ 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. */
const [hasReadySnapshot, setHasReadySnapshot] = useState(state.status === 'ready')
if (state.status === 'ready' && !hasReadySnapshot) setHasReadySnapshot(true)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [addOpen, setAddOpen] = useState(false)
const [addMode, setAddMode] = useState<AddMode>('catalog')
@@ -323,6 +326,14 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
</div>
)
}
if (!hasReadySnapshot && state.status !== 'ready') {
return (
<div className={styles['section']}>
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
</div>
)
}
// The saved provider as the directory currently names it. The route id is
// what the apply cannot change, so it is what the notice is keyed by; a row
@@ -486,8 +497,9 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
type="button"
className={styles['dangerButton']}
aria-label={providerCopy(t('removeProvider'), target)}
disabled={!state.writable}
disabled={!state.writable || state.status !== 'ready'}
onClick={() => {
if (controller.store.getSnapshot().status !== 'ready') return
setSavedTarget(undefined)
setDeleteFailure(undefined)
setDeleteTarget(target)
@@ -648,8 +660,9 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
<button
type="button"
className={styles['addButton']}
disabled={!state.writable || (!catalogEnabled && !customEnabled)}
disabled={!state.writable || state.status !== 'ready' || (!catalogEnabled && !customEnabled)}
onClick={() => {
if (controller.store.getSnapshot().status !== 'ready') return
const first = addable[0]
const initial: AddMode = catalogEnabled ? 'catalog' : 'custom'
setSavedTarget(undefined)
@@ -331,11 +331,71 @@ async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] =
}
describe('ModelsSection', () => {
it('waits for the current snapshot when a setup surface remounts during refresh', async () => {
const { view, controller, face, mirror, mutate } = await mountFirstRun()
view.unmount()
const before = controller.store.getSnapshot().namespaces.get('llm-deepseek')!
mirror.acceptView({
...before,
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://current.example', models: DEFAULT_DEEPSEEK_MODELS },
user: { baseURL: 'https://current.example' },
revision: 1,
})
const read = Promise.withResolvers<RemoteResult<Record<string, CredentialInfo>>>()
const started = Promise.withResolvers<undefined>()
face.credentials.describe.mockImplementationOnce(() => { started.resolve(undefined); return read.promise })
let refreshing: Promise<void> | undefined
try {
await act(async () => { refreshing = controller.load(); await started.promise })
expect(controller.store.getSnapshot().namespaces.get('llm-deepseek')?.revision).toBe(0)
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
operations={operationsWith(face)}
schema={settingsSchema}
t={t}
renderSlot={() => null}
/>)
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
expect(screen.queryByRole('button', { name: en.add })).toBeNull()
} finally {
await act(async () => { read.resolve(remoteOk({})); await refreshing })
}
await screen.findByLabelText(en.keyInput)
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://current.example')
fireEvent.change(url, { target: { value: 'https://next.example' } })
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(mutate).toHaveBeenCalledWith(
'llm-deepseek', [{ op: 'set', path: ['baseURL'], value: 'https://next.example' }], 1,
) })
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)
fireEvent.change(key, { target: { value: 'sk-unsaved-setup-draft' } })
const read = Promise.withResolvers<RemoteResult<Record<string, CredentialInfo>>>()
face.credentials.describe.mockImplementationOnce(() => read.promise)
let refreshing: Promise<void> | undefined
try {
act(() => { refreshing = controller.load() })
expect(screen.getByLabelText(en.keyInput)).toBe(key)
expect(key.value).toBe('sk-unsaved-setup-draft')
} finally {
await act(async () => { read.resolve(remoteOk({})); await refreshing })
}
expect(screen.getByLabelText(en.keyInput)).toBe(key)
expect(key.value).toBe('sk-unsaved-setup-draft')
})
it('checks current readiness before opening an editor and preserves an open draft during refresh', async () => {
const { controller } = await mountSection()
const edit = screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })
const ready = controller.store.getSnapshot()
// The store can publish loading before React commits the disabled button.
const snapshot = vi.spyOn(controller.store, 'getSnapshot').mockReturnValue({ ...ready, status: 'loading' })
try {
fireEvent.click(edit)
@@ -354,6 +414,102 @@ describe('ModelsSection', () => {
expect((edit as HTMLButtonElement).disabled).toBe(true)
})
it('checks current readiness before adding or starting removal from rendered rows', async () => {
const { controller, mutate, unset } = await mountSection()
const add = screen.getByRole('button', { name: en.add })
const remove = screen.getByRole('button', { name: openaiCopy(en.removeProvider) })
const ready = controller.store.getSnapshot()
const snapshot = vi.spyOn(controller.store, 'getSnapshot').mockReturnValue({ ...ready, status: 'loading' })
try {
fireEvent.click(add)
fireEvent.click(remove)
expect(screen.queryByLabelText(en.provider)).toBeNull()
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
expect(unset).not.toHaveBeenCalled()
} finally {
snapshot.mockRestore()
}
fireEvent.click(add)
expect(screen.getByLabelText(en.provider)).toBeTruthy()
})
it('blocks new Add and Remove actions while a joined refresh retains an open add draft', async () => {
const { controller, face } = await mountSection()
const add = screen.getByRole<HTMLButtonElement>('button', { name: en.add })
const remove = screen.getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.removeProvider) })
const read = Promise.withResolvers<RemoteResult<Record<string, CredentialInfo>>>()
face.credentials.describe.mockImplementationOnce(() => read.promise)
let refreshing: Promise<void> | undefined
try {
act(() => { refreshing = controller.load() })
expect(add.disabled).toBe(true)
expect(remove.disabled).toBe(true)
expect(screen.getByText('openai')).toBeTruthy()
fireEvent.click(add)
fireEvent.click(remove)
expect(screen.queryByLabelText(en.provider)).toBeNull()
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
} finally {
await act(async () => {
read.resolve(remoteOk({ OPENAI_API_KEY: { configured: true, writable: true } }))
await refreshing
})
}
fireEvent.click(add)
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-unsaved-add-draft' } })
act(() => { controller.store.update((state) => { state.status = 'loading' }) })
expect(key.value).toBe('sk-unsaved-add-draft')
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.add }).disabled).toBe(true)
})
it('waits for saved credential confirmation before removing its managed key', async () => {
const configured = new Set(['DEEPSEEK_API_KEY'])
const set = vi.fn((ref: string) => { configured.add(ref); return Promise.resolve(remoteOk(undefined)) })
const scripted = scriptedFace({ set })
const read = Promise.withResolvers<RemoteResult<Record<string, CredentialInfo>>>()
const started = Promise.withResolvers<undefined>()
let holdJoin = false
const answer = (refs: string[]): RemoteResult<Record<string, CredentialInfo>> => remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: configured.has(ref), writable: true }])),
)
scripted.face.credentials.describe.mockImplementation((refs: string[]) => {
if (holdJoin && refs.length > 1) { started.resolve(undefined); return read.promise }
return Promise.resolve(answer(refs))
})
const { controller, unset, mutate } = await mountFace(scripted)
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-new-managed-key' } })
holdJoin = true
try {
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await act(async () => { await started.promise })
expect(configured.has('OPENAI_API_KEY')).toBe(true)
expect(controller.store.getSnapshot().rows.find(row => row.entry.provider === 'openai')?.credential?.configured).toBe(false)
const remove = screen.getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.removeProvider) })
expect(remove.disabled).toBe(true)
fireEvent.click(remove)
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
holdJoin = false
await act(async () => { read.resolve(answer(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY'])) })
await screen.findByText(openaiCopy(en.savedProvider))
fireEvent.click(remove)
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
fireEvent.click(within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
await waitFor(() => { expect(unset).toHaveBeenCalledWith('OPENAI_API_KEY') })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
await waitFor(() => { expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() })
} finally {
holdJoin = false
await act(async () => { read.resolve(answer(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY'])) })
}
})
it('hides the add action when no settings namespace can open an editor', async () => {
const scripted = scriptedFace()
scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] }))