diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index bc8557c1a1..a5c0917a25 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: 4f46d6c1b70b2456 - zh: 9d2affd4a6b5d3a6 + en: db5aaf7f702e1f0c + zh: 409a78c14fb2b6e3 /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 97b4076277..def38dbba2 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. 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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index ae24f4dc59..0af8ee6abb 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 与凭据快照就绪时才显示保存状态。打开任一行的编辑器、添加提供商和打开移除确认都会等待该刷新。新打开的 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 在注册弹窗前校验它。它是页面初始化数据,不是持久化的完成标记。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index eea04c7702..cb5dc86b08 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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(undefined) const [addOpen, setAddOpen] = useState(false) const [addMode, setAddMode] = useState('catalog') diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 1e284a85a9..c331eb9e67 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -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 => 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(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>>() + const started = Promise.withResolvers() + 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(en.keyInput) + expect(key).not.toBe(oldKey) + expect(key.value).toBe('') + fireEvent.click(screen.getByText(en.customized)) + const url = screen.getByLabelText(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(en.keyInput)