fix(models): gate stale provider actions during refresh
This commit is contained in:
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]
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] }))
|
||||
|
||||
Reference in new issue
Block a user