diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 79acecbefe..3e3c096cab 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -30,8 +30,8 @@ en: 589397a35f82244e zh: 342fbceee2c173a2 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/extension-slots: - en: 6979796f0332268f - zh: 00896a005cc4f4e2 + en: 65e9d8822b67b6c2 + zh: 7e83be9defda28ee /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation: en: a87afbec5e339b36 zh: 6348c81af7e650e1 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 549177fbbb..ead2111e2b 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -61,7 +61,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs, and saves that catalog under the account route's own settings section (`llm-deepseek-account` by default), so an account edit never rewrites the `llm-deepseek` section the official route reads. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index e9bd2122c3..8f8e0720fb 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -61,7 +61,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框;目录保存到账号路由自己的设置段(默认为 `llm-deepseek-account`),因此账号侧的编辑不会改写 official 路由读取的 `llm-deepseek` 段。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 ----- diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index a50fe2933f..41dac376d1 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -182,8 +182,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const node = useMemo(() => schema.nodeAtPath(root, settingsPath), [root, schema, settingsPath]) const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy - const layout = layoutOf(namespace.ns) const accountProvider = props.provider === 'deepseek-account' + // Account settings use a configurable Cordis entry id. + const layout = accountProvider ? 'deepseek' : layoutOf(namespace.ns) const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. 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 1a2398bd2a..9396ff7f42 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -137,9 +137,29 @@ function wireNamespaces(): SettingsNamespaceView[] { secrets: [], revision: 4, }, + { + ns: 'llm-deepseek-account', + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, + value: { + baseURL: 'https://base', + defaultContextWindow: 1_000_000, + maxTokens: 256_000, + models: DEFAULT_DEEPSEEK_MODELS, + }, + base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, + user: {}, + autoGenerate: true, applies: 'live', + secrets: [], + revision: 0, + }, ] } +/** The account provider's settings namespace, under a composition's entry id. */ +function accountNamespace(ns = 'llm-deepseek-account'): SettingsNamespaceView { + return { ...wireNamespaces().find(view => view.ns === 'llm-deepseek-account')!, ns } +} + /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -1884,18 +1904,103 @@ describe('apiKeyFailure', () => { }) it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { - const scripted = scriptedFace({}) + const mutate = vi.fn(() => Promise.resolve(remoteOk(accountNamespace()))) + const scripted = scriptedFace({ mutate }) const ops = operationsWith(scripted.face) const describe = vi.spyOn(ops, 'describeCredential') + const namespace = accountNamespace() + const onClose = vi.fn() render( copy[key]} readOnly={false} onClose={() => {}} />) + namespace={namespace} settingsPath={[]} schema={settingsSchema} + operations={ops} t={key => copy[key]} readOnly={false} onClose={onClose} />) expect(screen.queryByLabelText(copy.keyInput)).toBeNull() expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() expect(describe).not.toHaveBeenCalled() expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + expect(screen.queryByText(content => content.includes(copy.advancedHint))).toBeNull() await expect(`${document.body.textContent}\n`) .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) + const set = vi.spyOn(ops, 'storeCredential') + fireEvent.change(screen.getAllByLabelText(new RegExp(copy.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) + fireEvent.click(screen.getByText(copy.apply)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek-account', + [{ + op: 'set', + path: ['models'], + value: [ + { + id: 'deepseek-v4-mini', + name: 'DeepSeek-V4-Flash', + description: 'Preserved hidden detail', + contextWindow: 1_000_000, + }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, + ], + }], + 0, + ]) + expect(set).not.toHaveBeenCalled() +}) + +it('keeps the DeepSeek editor for an account route under a renamed settings entry', async () => { + const namespace = accountNamespace('team-account-entry') + const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) + const ops = operationsWith(scriptedFace({ mutate }).face) + const set = vi.spyOn(ops, 'storeCredential') + const onClose = vi.fn() + render() + expect(screen.queryByText(content => content.includes(en.advancedHint))).toBeNull() + expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledWith('team-account-entry', expect.any(Array), 0) + expect(set).not.toHaveBeenCalled() +}) + +it('opens the account row from the section and saves to its own namespace', async () => { + const namespace = accountNamespace() + const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) + const { controller, set } = await mountSection({ mutate }) + const row = controller.store.getSnapshot().rows[0]! + await act(async () => { controller.store.update((state) => { + state.rows = [{ + ...row, + accountAvailable: true, + entry: { + provider: 'deepseek-account', displayName: en.deepSeekAccount, settingsNs: namespace.ns, settingsPath: [], active: true, + }, + }, ...state.rows] + }) }) + fireEvent.click(screen.getByRole('button', { + name: providerCopy(en.editProvider, { provider: 'deepseek-account', displayName: en.deepSeekAccount }), + })) + expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek-account', + [{ + op: 'set', + path: ['models'], + value: [ + { + id: 'deepseek-v4-mini', + name: 'DeepSeek-V4-Flash', + description: 'Preserved hidden detail', + contextWindow: 1_000_000, + }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, + ], + }], + 0, + ]) + expect(set).not.toHaveBeenCalled() }) it('renders the localized account row and supports catalogs without capacity defaults', async () => { @@ -1907,7 +2012,7 @@ it('renders the localized account row and supports catalogs without capacity def }) }) expect(screen.getByText(en.deepSeekAccount)).toBeTruthy() view.unmount() - const namespace = wireNamespaces()[0]! + const namespace = accountNamespace() render( {}} />) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 79905b4441..007ff68099 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -62,6 +62,15 @@ const NAMESPACES = [ secrets: [], revision: 0, }, + { + ns: 'llm-deepseek-account', + schema: {}, + value: { baseURL: 'https://base' }, + base: { baseURL: 'https://base' }, + autoGenerate: true, applies: 'live' as const, + secrets: [], + revision: 0, + }, { ns: 'llm-pi-ai', schema: {}, @@ -346,7 +355,7 @@ describe('edge joins', () => { it.each([false, true])('uses account availability without asking for an API key: %s', async (accountAvailable) => { const { ctx, mirror, seenRefs } = api({ accountAvailable, providers: async () => ok({ providers: [{ - provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek-account', settingsPath: [], active: true, }] }) }) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() @@ -356,12 +365,13 @@ it.each([false, true])('uses account availability without asking for an API key: expect(rows[0]).toMatchObject({ accountAvailable: true, apiKeyEnv: undefined, credential: undefined }) expect(providerUsable(rows[0]!)).toBe(true) } + expect(store.store.getSnapshot().namespaces.get('llm-deepseek-account')?.ns).toBe('llm-deepseek-account') expect(seenRefs).toEqual([]) }) it('removes the account row after sign-out and restores it after sign-in', async () => { const overrides = { accountAvailable: true, providers: async () => ok({ providers: [{ - provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek-account', settingsPath: [], active: true, }, ...DIRECTORY] }) } const { ctx, mirror } = api(overrides) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)