From bd20fc56bbd81ca4b8722b1c6f59f5bb34a9657c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:09:14 +0800 Subject: [PATCH] fix(models): gate stale provider actions during refresh --- .../models-settings/refreshing.expected.md | 6 +- apps/web/tests/models-settings.e2e.ts | 40 ++++- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/ModelsSection.tsx | 17 +- .../tests/components.client.spec.tsx | 158 +++++++++++++++++- 7 files changed, 216 insertions(+), 13 deletions(-) diff --git a/apps/web/tests/expected/models-settings/refreshing.expected.md b/apps/web/tests/expected/models-settings/refreshing.expected.md index 96cdfa3bb0..ad9ac8cda1 100644 --- a/apps/web/tests/expected/models-settings/refreshing.expected.md +++ b/apps/web/tests/expected/models-settings/refreshing.expected.md @@ -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] diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 7c68a3b2a1..9cb6c5bbc2 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -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) => { + 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() + 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() })) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index aef2a2aa09..bc8557c1a1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c7a603e245..97b4076277 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 25086f94e7..ae24f4dc59 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -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 在注册弹窗前校验它。它是页面初始化数据,不是持久化的完成标记。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 4dbf276c36..eea04c7702 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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(undefined) const [addOpen, setAddOpen] = useState(false) const [addMode, setAddMode] = useState('catalog') @@ -323,6 +326,14 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS ) } + if (!hasReadySnapshot && state.status !== 'ready') { + return ( +
+

{t('title')}

+

{t('intro')}

+
+ ) + } // 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