From 855461c2e8aa57cdef69a68616879bc12977df70 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 15:09:30 +0800 Subject: [PATCH 1/3] feat(ui-settings-models): open provider-card and footer extension slots The Models section now declares two SlotMap seats for out-of-tree plugins: settings.models.provider-card (keyed by the row's settingsNs, dispatched on saved cards, the first-run setup posture, and the add-provider draft, with the row view, configured join, and confirmed api-key state as owner props) and settings.models.footer (ordered list after the rows and add controls). Without registrants both seats render nothing. First consumer: the llm-pi-ai-oauth companion plugin's sign-in surface. --- ...8-26-models-page-extension-slots.i18n.yaml | 6 ++ .../2026-08-26-models-page-extension-slots.md | 33 ++++++++ ...26-08-26-models-page-extension-slots.zh.md | 33 ++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 + .../client/ui-settings-models/README.zh.md | 4 + .../src/client/ModelsSection.tsx | 52 ++++++++++-- .../ui-settings-models/src/client/index.ts | 5 ++ .../src/client/slot-contract.ts | 56 +++++++++++++ .../tests/apply.client.spec.ts | 25 ++++++ .../tests/components.client.spec.tsx | 71 +++++++++++++++- .../tests/provider-form.client.spec.tsx | 1 + .../src/client/slot-catalog.ts | 80 +++++++++++++++++++ 13 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md create mode 100644 packages/client/ui-settings-models/src/client/slot-contract.ts diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml new file mode 100644 index 0000000000..e31c3b95ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md +2026-08-26-models-page-extension-slots.md: e1ad30fd7283470b76d302508404d7c1445d5e50 +2026-08-26-models-page-extension-slots.zh.md: 4e7aa7cef0acbcf2e8344f31d4d458fde30c889c diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md new file mode 100644 index 0000000000..e1ad30fd72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md @@ -0,0 +1,33 @@ +# Agent Note: Models-page extension slots + +Status: implemented + +English | [中文](2026-08-26-models-page-extension-slots.zh.md) + +## Problem + +Provider sign-in for the pi-ai catalog (GitHub Copilot, OpenAI accounts) is moving out of the product into an optional out-of-tree plugin for provider terms-of-service reasons. The plugin needs its sign-in button and attempt UI inside the Models page's provider cards — the surface where a user meets a provider — but `ui-settings-models` rendered its cards from closed code: the only integration path was editing this package, which an external plugin cannot do, and the page's one open seam (`settings.section`) can only add a whole separate page. + +## Decision + +`ui-settings-models` declares two SlotMap seats in `src/client/slot-contract.ts`, claims them as `children` of its `settings.section` registration, and re-exports their types from `./client` so an out-of-tree plugin can merge them with a type-only import. + +`settings.models.provider-card` is `keyed` with `entryKey = ConfigurableProviderView.settingsNs`: one registration under an adapter family's settings namespace receives every card of that family — shipped catalog routes, adopted directory rows, and hand-declared routes alike — while the section never interprets the key. The key domain stays the open string space (no `keyProps` table) because hand-declared route ids are user-chosen at runtime. The seat dispatches on every card that shows a directory row: a saved row's card, its first-run setup posture, and the add-provider draft (its dormant row, `configured: false` in practice), which is where sign-in matters most — the user has just met the provider and holds no key. The hand-declared draft card has no directory row before saving and dispatches nothing. Owner props carry the row's `ConfigurableProviderView`, its `configured` join, and its confirmed api-key credential state (`keyConfigured`, which the first consumer uses to withhold sign-in beside a stored key); nothing more has a current consumer. + +`settings.models.footer` is a `list` seat after the rows and the add controls, for section-level extension content such as orphaned-record management. + +Without registrants both seats render nothing, so the shipped page is pixel-identical to before. + +## Alternatives considered + +**A `list` seat with self-filtering registrants instead of keying.** Every registrant would render (and return null) on every card, and two plugins could silently interleave UI inside one family's cards. Keying by namespace gives one accountable extension owner per adapter family and zero wasted dispatches, and reuses the exact pairing rationale of `settings.plugin.item`. + +**Keying by provider route id.** Route ids are dynamic — hand-declared routes are named by users at runtime — so a plugin could not register ahead of the rows it wants and would have to churn registrations as the directory changes. + +**A `chain` seat replacing the whole card body.** No current consumer needs to replace the editor; the sign-in surface is additive. A takeover contract would also make the section's layout a compatibility surface. A chain can still be added later without disturbing these seats. + +**Keeping the sign-in UI wired inside `ui-settings-models` (the pre-plugin design).** Ships the terms-of-service-sensitive surface in the product, which is the outcome this extension point exists to avoid. + +## Consequences + +An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. One extension owner per adapter family is a deliberate limit — a second plugin claiming the same namespace is refused by the keyed registry rather than silently merged. diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md new file mode 100644 index 0000000000..4e7aa7cef0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Models 页扩展插槽 + +Status: implemented + +[English](2026-08-26-models-page-extension-slots.md) | 中文 + +## Problem + +出于提供方服务条款的考虑,pi-ai catalog 的提供方登录(GitHub Copilot、OpenAI 账号)正从产品中移出,改由一个可选的仓库外插件承担。该插件需要把登录按钮与登录过程 UI 放进 Models 页的提供方卡片——用户与提供方相遇的界面——但 `ui-settings-models` 的卡片由封闭代码渲染:唯一的集成途径是修改本包,外部插件做不到;页面仅有的开放缝隙(`settings.section`)只能新增一整个独立页面。 + +## Decision + +`ui-settings-models` 在 `src/client/slot-contract.ts` 声明两个 SlotMap 席位,在其 `settings.section` 注册中以 `children` 认领它们,并从 `./client` 再导出其类型,使仓库外插件通过 type-only import 即可获得类型合并。 + +`settings.models.provider-card` 为 `keyed`,`entryKey = ConfigurableProviderView.settingsNs`:以某适配器家族的 settings namespace 注册一次,即可收到该家族的全部卡片——内置 catalog 路由、从目录采纳的行、手工声明的路由一视同仁——而分区从不解释这个 key。键域保持开放字符串空间(不设 `keyProps` 表),因为手工声明的路由 id 由用户在运行时命名。该席位在每张展示目录行的卡片上分发:已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡(其休眠行,实际为 `configured: false`)——草稿卡正是登录价值最大的时刻:用户刚遇到该提供方、手中还没有密钥。手工声明的草稿卡在保存前没有目录行,不分发。Owner props 携带该行的 `ConfigurableProviderView`、其 `configured` 合并结果与已确认的 api-key 凭据状态(`keyConfigured`,首个消费者用它在已存密钥旁抑制登录入口);更多字段没有现役消费者。 + +`settings.models.footer` 为 `list` 席位,位于行列表与新增控件之后,承载孤儿记录管理这类分区级扩展内容。 + +没有注册方时两个席位均不渲染,产品页面与之前逐像素一致。 + +## Alternatives considered + +**用 `list` 席位、由注册方自行筛选,替代按键分发。**每个注册方都会在每张卡片上渲染(再返回 null),且两个插件可能在同一家族的卡片里静默交错 UI。按 namespace 分发让每个适配器家族有唯一可问责的扩展所有者、零浪费分发,并完全复用 `settings.plugin.item` 的配对理由。 + +**按提供方路由 id 分发。**路由 id 是动态的——手工声明的路由由用户在运行时命名——插件无法先于目标行注册,还得随目录变化反复重注册。 + +**用 `chain` 席位整体接管卡片。**没有现役消费者需要替换编辑器;登录界面是加法。接管契约还会让分区布局成为兼容面。将来仍可在不动这两个席位的前提下追加 chain。 + +**把登录 UI 继续织在 `ui-settings-models` 里(插件化之前的设计)。**会把服务条款敏感的界面随产品发布,而这正是本扩展点要避免的结果。 + +## Consequences + +仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族只有一个扩展所有者是刻意的限制——第二个插件认领同一 namespace 会被 keyed 注册表拒绝,而不是被静默合并。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index bb3148b3f1..35e7f7557a 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: c1d2e08a32d10f6d0e2bb9bb13e085d69d6ece92 -README.zh.md: 0204494c7856b96d6f0c17c580abea31e49c3516 +README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da +README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1d2e08a32..6ed2bde147 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -47,6 +47,10 @@ The add flow is a card carrying the dormant-directory provider select — a bare After the versioned notice step completes, the DeepSeek step projects first-run readiness from the same joined snapshot. ANY provider the user can already reach ends it without rendering; only a user with none is asked for the official DeepSeek key. Configure later completes only this coordinator pass, and an absent adapter, inactive route, failed join, read-only deployment, or unusable capability completes the step without rendering — Models remains the diagnostic surface. +### Extension slots + +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. + ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0204494c78..3806244d4d 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -47,6 +47,10 @@ kind: "package-reference" 版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。 +### 扩展插槽 + +本分区为仓库外分发的插件声明两个席位,类型定义在 [`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` 入口;没有注册方时两个席位均不渲染任何内容。 + ----- diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 9501185e87..2e7b01899a 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -16,7 +16,9 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls this package's SlotMap merge (the two Models child slots). +import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsStore, ProviderRow } from './store.ts' @@ -42,11 +44,18 @@ export interface ModelsSectionInjected { t: (key: keyof typeof en) => string } +/** The child slots this section declares and dispatches (see ./slot-contract.ts). */ +type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer' + +/** The child-slot dispatch function the renderer binds for the section. */ +type ModelsRenderSlot = PropsRenderSlots['renderSlot'] + /** * Props delivered by the slot outlet: the inject face spread flat (the - * renderer erases the share boundary at the render call). + * renderer erases the share boundary at the render call) plus the child-slot + * dispatch seat. */ -export type ModelsSectionProps = Partial> +export type ModelsSectionProps = Partial> & Partial> type ModelsSectionFace = InjectFace @@ -139,6 +148,11 @@ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { return row.credential?.configured !== true } +/** The provider-card seat's credential fact: a referenced key confirmed configured. */ +function keyConfiguredOf(row: ProviderRow): boolean { + return row.apiKeyEnv !== undefined && row.credential?.configured === true +} + function targetOf(row: ProviderRow): EditorTarget { const managedRef = deriveKeyRef(row.entry.provider) const credentialRef = row.apiKeyEnv === managedRef @@ -175,15 +189,15 @@ export function providerCopy(template: string, target: ProviderIdentity): string * @returns the section, or null while the shell has not injected yet. */ export function ModelsSection(props: ModelsSectionProps): ReactNode { - const { controller, useSnapshot, api, schema, t } = props + const { controller, useSnapshot, api, schema, t, renderSlot } = props if ( controller === undefined || useSnapshot === undefined || api === undefined - || schema === undefined || t === undefined + || schema === undefined || t === undefined || renderSlot === undefined ) return null - return + return } -function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { +function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { const { controller, api, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) @@ -275,6 +289,12 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') const addTarget = adding ? editing : undefined const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs) + // The draft's directory row, for the card extension seat. A refresh can drop + // the row mid-draft (the route was adopted or withdrawn elsewhere); the + // draft card stays while the seat simply has no row to dispatch. + const addRow = addTarget === undefined + ? undefined + : state.rows.find(row => row.entry.provider === addTarget.provider) // Hand-declared routes live in the pi-ai namespace, which is also the only // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. @@ -324,6 +344,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, })} + {renderSlot( + 'settings.models.provider-card', + { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, + { entryKey: row.entry.settingsNs }, + )} ) } @@ -399,6 +424,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { : null} + {renderSlot( + 'settings.models.provider-card', + { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, + { entryKey: row.entry.settingsNs }, + )} {open ? renderProviderEditor({ target, @@ -449,6 +479,13 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} /> + {addRow === undefined + ? null + : renderSlot( + 'settings.models.provider-card', + { provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) }, + { entryKey: addRow.entry.settingsNs }, + )} ) : declaring @@ -509,6 +546,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { )} + {renderSlot('settings.models.footer', {})} t('nav'), inject: injected, + children: { + 'settings.models.provider-card': { kind: 'keyed', scope: 'root' }, + 'settings.models.footer': { kind: 'list', scope: 'root' }, + }, }, ModelsSection)) ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ name: 'settings.onboarding', diff --git a/packages/client/ui-settings-models/src/client/slot-contract.ts b/packages/client/ui-settings-models/src/client/slot-contract.ts new file mode 100644 index 0000000000..b8360e21c9 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/slot-contract.ts @@ -0,0 +1,56 @@ +/** + * Models-page extension slots — the two seats through which a plugin + * distributed outside this repository adds UI to the Models settings section + * without editing it. + * + * `settings.models.provider-card` is keyed by the row's owning settings + * namespace (`ConfigurableProviderView.settingsNs`): an adapter family's + * companion plugin registers one entry under the family's namespace and + * receives every card of that family — shipped, added, and hand-declared rows + * alike — while the section never learns what the namespace means. Keying on + * the namespace follows `settings.plugin.item`, and the key domain stays the + * open string space because hand-declared route ids are user-chosen at + * runtime. + * + * TYPE HOME RATIONALE: the Models section declares these slots at runtime, + * and a plugin registering an extension already depends on this package for + * the declaration. The types therefore live with their declarer. + */ + +import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * One provider card's adapter extension area, dispatched with + * `entryKey = settingsNs` on every card that renders a directory row: a + * saved row's card (its first-run setup posture included) and the + * add-provider draft card. The hand-declared draft card has no directory + * row yet, so it dispatches nothing until saved. Without a registrant the + * area renders nothing. + */ + 'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps } + /** + * Ordered extension area after the provider rows and the add controls. + * Without a registrant the area renders nothing. + */ + 'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps } + } +} + +/** Owner share of one provider-card extension occurrence. */ +export interface ProviderCardExtrasOwnerProps { + /** The card's directory row (route id, display name, settings address, live state). */ + provider: ConfigurableProviderView + /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */ + configured: boolean + /** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */ + keyConfigured: boolean +} + +/** Owner share of the footer area (the section supplies nothing). */ +export interface ModelsFooterOwnerProps { + /** Marker field: footer owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index a3c8a1a8ea..c4d569f77b 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -61,6 +61,9 @@ describe('ui-settings-models apply', () => { const entry = before.slots.entries('settings.section')[0]! expect(entry.component).toBe(ModelsSection) expect(entry.options).toMatchObject({ id: 'models', order: 10 }) + // The section claims its two extension seats in the same registration. + expect(before.slots.spec('settings.models.provider-card')).toMatchObject({ kind: 'keyed', scope: 'root' }) + expect(before.slots.spec('settings.models.footer')).toMatchObject({ kind: 'list', scope: 'root' }) // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() @@ -137,6 +140,28 @@ describe('ui-settings-models apply', () => { b.locale.setLocale('zh') }) + it('accepts extension entries under the declared seats and cascades them with the declarer', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + // A keyed card extension and a footer entry register through the ordinary + // ledger once the section's registration declared the seats. + const disposeCard = b.slots.register( + { name: 'settings.models.provider-card', key: 'llm-pi-ai' } as never, + () => null, + ) + b.slots.register({ name: 'settings.models.footer', id: 'extra', order: 0 } as never, () => null) + expect(b.slots.entries('settings.models.provider-card')).toHaveLength(1) + expect(b.slots.entries('settings.models.footer')).toHaveLength(1) + // Extension-side HMR safety: its own disposer removes the entry. + disposeCard() + expect(b.slots.entries('settings.models.provider-card')).toHaveLength(0) + // Declarer unload cascades whatever extension entries remain. + await fiber.dispose() + expect(b.slots.entries('settings.models.footer')).toHaveLength(0) + }) + it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => { const b = await bench() declare(b.slots) 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 5d0cbe8fed..edfb6462ee 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -195,20 +195,44 @@ function scriptedFace(overrides: { type WireFace = ConstructorParameters[0] +/** One recorded child-slot dispatch: seat name, owner share, kind options. */ +type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] + +/** Child-slot dispatch stub: records every seat occurrence, renders nothing. */ +function stubRenderSlot() { + return vi.fn((..._call: RenderSlotCall) => null) +} + +/** The provider-card seat dispatches a stub recorded, as (route id, configured, keyConfigured, entryKey). */ +function cardSeatCalls( + renderSlot: ReturnType, +): Array<[string, boolean, boolean, string | undefined]> { + return renderSlot.mock.calls + .filter(call => call[0] === 'settings.models.provider-card') + .map(call => [ + (call[1] as { provider: { provider: string } }).provider.provider, + (call[1] as { configured: boolean }).configured, + (call[1] as { keyConfigured: boolean }).keyConfigured, + call[2]?.entryKey, + ]) +} + async function mountFace(scripted: ReturnType) { const { face, update, replace, mutate, set, unset } = scripted const mirror = new SettingsDescribeMirror(face as never) const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror) await controller.load() + const renderSlot = stubRenderSlot() const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), api: face as never, schema: settingsSchema, t, + renderSlot: renderSlot as unknown as NonNullable, } const view = render() - return { view, face, update, replace, mutate, set, unset, controller, mirror } + return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot } } async function mountSection(overrides: Parameters[0] = {}) { @@ -246,6 +270,45 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) + it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => { + const { renderSlot } = await mountSection() + const cards = cardSeatCalls(renderSlot) + expect(cards).toContainEqual(['openai', true, true, 'llm-pi-ai']) + expect(cards).toContainEqual(['deepseek-official', true, false, 'llm-deepseek']) + // The footer seat renders once below the rows and the add controls. + expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.models.footer')).toEqual([ + ['settings.models.footer', {}], + ]) + }) + + it('dispatches the provider-card seat inside the first-run setup card', async () => { + const { renderSlot } = await mountFirstRun() + expect(cardSeatCalls(renderSlot)).toContainEqual(['deepseek-official', true, false, 'llm-deepseek']) + }) + + it('dispatches the provider-card seat on the add-provider draft with its dormant row', async () => { + const { renderSlot } = await mountSection() + renderSlot.mockClear() + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai']) + }) + + it('skips the draft seat when a refresh drops the dormant row', async () => { + const { renderSlot, face, controller } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + face.llm.providers.mockImplementation(() => Promise.resolve(ok({ + providers: [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + ], + }))) + renderSlot.mockClear() + await act(async () => { await controller.load() }) + // The draft card is still open while its row is gone from the directory. + expect(screen.getByLabelText(en.keyInput)).toBeTruthy() + expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false) + }) + it('persists the default-off subagent model-selection switch for new sessions', async () => { const enabledNamespace: SettingsNamespaceView = { ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, @@ -352,6 +415,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) const missing = screen.getByRole('img', { name: en.credentialMissing }) @@ -376,6 +440,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) // Now a row with an Edit button, not an open card. expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1) @@ -1106,6 +1171,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) const key = await screen.findByLabelText(en.keyInput) expect(key.placeholder).toBe(en.keyPlaceholder) @@ -1245,6 +1311,7 @@ describe('ModelsSection', () => { api={face.face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) expect(screen.getByText(/directory down/)).toBeTruthy() fireEvent.click(screen.getByText(en.retry)) @@ -1267,6 +1334,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText(en.remove).every(button => button.disabled)).toBe(true) @@ -1328,6 +1396,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} + renderSlot={stubRenderSlot() as unknown as NonNullable} />) await screen.findByText('DeepSeek') }) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 977310aab0..892a1ccb73 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -150,6 +150,7 @@ async function mountSection(options: Parameters[0] = {}) { api: scripted.face as never, schema: settingsSchema, t, + renderSlot: () => null, } render() return { ...scripted, controller } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c599a0eb3c..9dc0c9f3fe 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1558,6 +1558,86 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-settings/src/client/contract/slots.ts:30', }, + { + key: 'settings.models.footer', + kind: 'list', + scope: 'root', + summary: 'Ordered extension area after the provider rows and the add controls.', + doc: 'Ordered extension area after the provider rows and the add controls.\nWithout a registrant the area renders nothing.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n /** Marker field: footer owner props are intentionally empty. */\n children?: never\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.footer\', () => ctx.slots.register(\n { name: \'settings.models.footer\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:38', + }, + { + key: 'settings.models.provider-card', + kind: 'keyed', + scope: 'root', + summary: 'One provider card\'s adapter extension area, dispatched with `entryKey = settingsNs` on every card that renders a directory row: a saved row\'s card (its first-run setup posture included) and the add-provider draft card.', + doc: 'One provider card\'s adapter extension area, dispatched with\n`entryKey = settingsNs` on every card that renders a directory row: a\nsaved row\'s card (its first-run setup posture included) and the\nadd-provider draft card. The hand-declared draft card has no directory\nrow yet, so it dispatches nothing until saved. Without a registrant the\narea renders nothing.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card\'s directory row (route id, display name, settings address, live state). */\n provider: ConfigurableProviderView\n /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n configured: boolean\n /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n keyConfigured: boolean\n}', + ], + ownerPropsReferences: [ + 'ConfigurableProviderView', + ], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.provider-card\', () => ctx.slots.register(\n { name: \'settings.models.provider-card\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:33', + }, { key: 'settings.onboarding', kind: 'list', From b5c3cc897c931ef99856c04502acdb15879ff6ae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 16:37:49 +0800 Subject: [PATCH 2/3] fix(llm-pi-ai): store the JSON image of a grant payload pi-ai credentials carry optional members as explicit undefined (a github.com Copilot grant holds enterpriseUrl: undefined), and the store bridge committed the object verbatim, so the credential store's strict validator refused the write and sign-in failed after the provider had already authorized it. toRecord now drops explicitly-undefined members and renders undefined array entries null, exactly as JSON.stringify would; everything else passes through untouched so genuinely unstorable values still fail loud at the store. --- ...6-pi-ai-grant-payload-json-image.i18n.yaml | 6 +++ ...26-08-26-pi-ai-grant-payload-json-image.md | 29 +++++++++++++ ...08-26-pi-ai-grant-payload-json-image.zh.md | 29 +++++++++++++ packages/llm/llm-pi-ai/src/auth.ts | 26 +++++++++++- packages/llm/llm-pi-ai/tests/auth.spec.ts | 41 +++++++++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml new file mode 100644 index 0000000000..de1f46c4a5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md +2026-08-26-pi-ai-grant-payload-json-image.md: 9eb5e5ffd9ccbc18d409bd50e05061006bdca094 +2026-08-26-pi-ai-grant-payload-json-image.zh.md: 8329b4acf062a073dfe78ec10fe03852e7a64fd9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md new file mode 100644 index 0000000000..9eb5e5ffd9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md @@ -0,0 +1,29 @@ +# Agent Note: pi-ai grant payloads store their JSON image + +Status: implemented + +English | [中文](2026-08-26-pi-ai-grant-payload-json-image.zh.md) + +## Problem + +A GitHub Copilot sign-in against github.com failed at its commit step: `credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`. pi-ai's Copilot credential carries its optional members as explicit `undefined` (`enterpriseUrl: undefined` when no Enterprise domain was given — idiomatic JavaScript that `JSON.stringify` would simply drop), and `llm-pi-ai`'s store bridge committed the credential object verbatim as the grant payload. The credential store's validator rightly refuses `undefined` as unrepresentable, so every grant whose flow left an optional member unset failed to store, and the sign-in reported failure after the provider had already authorized it. + +## Decision + +`toRecord` in `packages/llm/llm-pi-ai/src/auth.ts` stores the JSON image of a grant credential: `jsonImage` drops explicitly-undefined members of plain objects and renders undefined array entries as `null`, exactly as `JSON.stringify` would. Everything else — non-finite numbers, foreign-prototype objects — passes through untouched, so a genuinely unstorable value still fails loud at the store's validator rather than being silently reshaped. Reading back is unchanged: an absent member and an explicitly-undefined one are indistinguishable to pi-ai's consumers, which access optional members by property read. + +## Testing + +`tests/auth.spec.ts` writes the Copilot-shaped grant (explicit `undefined` member, nested drop, array hole) through the real `LocalCredentialProvider` and asserts the stored payload is the JSON image; a second case proves the fail-loud path survives by committing a `Date`-valued member and asserting the store's refusal reaches the caller. + +## Alternatives considered + +**`JSON.parse(JSON.stringify(credential))`.** Rejected: it also renders `NaN`/`Infinity` as `null` and runs `toJSON` methods, silently reshaping exactly the values the strict validator exists to refuse loudly. + +**Relaxing the store validator to skip `undefined` members.** Rejected: the seam stores payloads it never reads or reshapes, and every producer relies on byte-faithful round-trips; normalization belongs to the producer that knows its library's idiom, not to the store every plugin shares. + +**Fixing pi-ai upstream to omit unset members.** Out of this repository's hands and version-fragile: any future flow reintroducing the idiom would break sign-in again. The bridge owning the translation makes the harness robust against the whole class. + +## Consequences + +Grants from every pi-ai flow store regardless of which optional members the flow left unset. The bridge now owns a one-way normalization: a payload read back lacks members that were explicitly `undefined` at write time, which is indistinguishable from their absence for property access, and remains the documented JSON semantics. diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md new file mode 100644 index 0000000000..8329b4acf0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md @@ -0,0 +1,29 @@ +# Agent Note: pi-ai grant payload 落盘其 JSON 像 + +Status: implemented + +[English](2026-08-26-pi-ai-grant-payload-json-image.md) | 中文 + +## Problem + +一次面向 github.com 的 GitHub Copilot 登录在提交环节失败:`credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`。pi-ai 的 Copilot 凭据以显式 `undefined` 携带可选成员(未填 Enterprise 域名时为 `enterpriseUrl: undefined`——这是 `JSON.stringify` 会直接丢弃的 JavaScript 惯用写法),而 `llm-pi-ai` 的存储桥接把凭据对象原样作为 grant payload 提交。凭据存储的校验器正当地拒绝 `undefined` 为不可表示,于是所有流程留有未填可选成员的 grant 都无法落盘,提供方已经完成授权之后登录却报失败。 + +## Decision + +`packages/llm/llm-pi-ai/src/auth.ts` 的 `toRecord` 改为落盘 grant 凭据的 JSON 像:`jsonImage` 丢弃普通对象里显式为 undefined 的成员,把数组中的 undefined 条目渲染为 `null`,与 `JSON.stringify` 完全一致。其余一切——非有限数、异种原型对象——原样透传,因此真正不可存储的值仍会在存储校验器处大声失败,而不是被静默改写。读回不变:成员缺失与显式 undefined 对以属性读取访问可选成员的 pi-ai 消费方不可区分。 + +## Testing + +`tests/auth.spec.ts` 经真实 `LocalCredentialProvider` 写入 Copilot 形状的 grant(显式 `undefined` 成员、嵌套丢弃、数组空洞),断言落盘 payload 为 JSON 像;第二个用例提交带 `Date` 成员的 grant 并断言存储的拒绝到达调用方,证明 fail-loud 路径仍在。 + +## Alternatives considered + +**`JSON.parse(JSON.stringify(credential))`。**否决:它还会把 `NaN`/`Infinity` 渲染为 `null` 并执行 `toJSON` 方法,把严格校验器本要大声拒绝的值静默改写掉。 + +**放宽存储校验器、跳过 undefined 成员。**否决:seam 存储的 payload 它从不读取或改写,所有生产方都依赖逐字节往返;归一化属于了解自家库惯用法的生产方,而不是所有插件共享的存储。 + +**在 pi-ai 上游修掉未填成员。**不在本仓库掌控内且随版本脆弱:将来任何流程重新引入该惯用法都会再次弄坏登录。由桥接层拥有这次翻译,让 harness 对整类问题免疫。 + +## Consequences + +所有 pi-ai 流程的 grant 无论留空哪些可选成员都能落盘。桥接层现在拥有一次单向归一化:写入时显式为 undefined 的成员在读回时缺失,这对属性访问不可区分,且正是文档化的 JSON 语义。 diff --git a/packages/llm/llm-pi-ai/src/auth.ts b/packages/llm/llm-pi-ai/src/auth.ts index f729a77f85..3d70de9815 100644 --- a/packages/llm/llm-pi-ai/src/auth.ts +++ b/packages/llm/llm-pi-ai/src/auth.ts @@ -37,6 +37,30 @@ export function recordKeyFor(providerId: string): CredentialKey { return credentialKey(RECORD_SCOPE, providerId) } +/** + * The JSON image of one grant payload: plain objects lose their + * explicitly-undefined members and array entries JSON cannot hold become + * null, exactly as `JSON.stringify` would render them. pi-ai credentials + * idiomatically carry optional members as explicit `undefined` (a github.com + * Copilot grant holds `enterpriseUrl: undefined`), which the credential + * store's strict validator refuses as unrepresentable. Everything else — + * non-finite numbers and foreign prototypes included — passes through + * untouched, so a genuinely unstorable value still fails loud at the store. + * @param value - the value to render. + * @returns the value's JSON image. + */ +function jsonImage(value: unknown): unknown { + if (Array.isArray(value)) return value.map(entry => entry === undefined ? null : jsonImage(entry)) + if (typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype) { + const image: Record = {} + for (const [key, member] of Object.entries(value)) { + if (member !== undefined) image[key] = jsonImage(member) + } + return image + } + return value +} + /** * Translate a stored record into the credential pi-ai expects. * @@ -72,7 +96,7 @@ function toRecord(credential: Credential): CredentialRecord { ...credential.env === undefined ? {} : { env: { ...credential.env } }, } } - return { kind: 'grant', payload: credential } + return { kind: 'grant', payload: jsonImage(credential) } } /** diff --git a/packages/llm/llm-pi-ai/tests/auth.spec.ts b/packages/llm/llm-pi-ai/tests/auth.spec.ts index 27a496a2c3..def5b4229a 100644 --- a/packages/llm/llm-pi-ai/tests/auth.spec.ts +++ b/packages/llm/llm-pi-ai/tests/auth.spec.ts @@ -64,6 +64,47 @@ describe('pi-ai credential store over harness records', () => { await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted }) }) + it('stores the JSON image of a grant, dropping explicitly-undefined members', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + // The github.com Copilot shape: pi-ai sets optional members to explicit + // undefined, which the strict record validator refuses verbatim. + const granted = { + type: 'oauth' as const, + access: 'at', + refresh: 'rt', + expires: 42, + enterpriseUrl: undefined, + nested: { keep: 'x', drop: undefined }, + list: ['a', undefined, 'b'], + } + + await store.modify('github-copilot', () => Promise.resolve(granted)) + + await expect(ctx.credentials.readRecord(recordKeyFor('github-copilot'))).resolves.toEqual({ + kind: 'grant', + payload: { + type: 'oauth', + access: 'at', + refresh: 'rt', + expires: 42, + nested: { keep: 'x' }, + list: ['a', null, 'b'], + }, + }) + }) + + it('passes a genuinely unstorable grant value through to the store\'s loud refusal', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + // A foreign-prototype member is not the undefined idiom: the image leaves + // it untouched and the record validator still refuses the write. + const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, issued: new Date(0) } + + await expect(store.modify('github-copilot', () => Promise.resolve(granted))) + .rejects.toThrow(/JSON cannot represent/) + }) + it('shows the mutation the current credential and leaves it alone when declined', async () => { const store = credentialStoreFrom(await stored()) await store.modify('openai-codex', () => From bf0db65bb0e9b6e080a0328a01622c92cc032c98 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 17:12:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui-settings-models):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20derived=20key=20fact,=20required=20render=20seat?= =?UTF-8?q?,=20spec=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-card seat's keyConfigured now derives from the reference the page would use — the profile's apiKeyEnv, or the page's derived _API_KEY while the profile names none — so the add-provider draft agrees with its own editor about an existing conventional credential (the store joins the derived describe in the same batched call, as ProviderRow.derivedCredential). ModelsSectionProps makes the renderSlot seat required so a direct render that forgets it fails to compile; the one such render in provider-form.client.spec regained a real mount and the test boilerplate collapsed to renderSlot={() => null}. The extension-slots Agent Note now states the keyed cell's real override rule (same priority throws, a different priority shadows), and docs/subsystems/slots.md carries the two new seats in its hierarchy, both languages. --- ...8-26-models-page-extension-slots.i18n.yaml | 4 +-- .../2026-08-26-models-page-extension-slots.md | 2 +- ...26-08-26-models-page-extension-slots.zh.md | 2 +- docs/subsystems/slots.i18n.yaml | 4 +-- docs/subsystems/slots.md | 2 ++ docs/subsystems/slots.zh.md | 2 ++ .../src/client/ModelsSection.tsx | 20 +++++++++---- .../ui-settings-models/src/client/store.ts | 24 ++++++++++----- .../tests/components.client.spec.tsx | 30 ++++++++++++++----- .../tests/invariant.client.spec.ts | 3 +- .../tests/provider-form.client.spec.tsx | 1 + .../tests/store.client.spec.ts | 18 ++++++++--- 12 files changed, 82 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml index e31c3b95ce..e6e2dae0a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md -2026-08-26-models-page-extension-slots.md: e1ad30fd7283470b76d302508404d7c1445d5e50 -2026-08-26-models-page-extension-slots.zh.md: 4e7aa7cef0acbcf2e8344f31d4d458fde30c889c +2026-08-26-models-page-extension-slots.md: 9983c669b7838de00d49a634a54e5d115ef4945a +2026-08-26-models-page-extension-slots.zh.md: 639654ea4b912d43a95f0a9295969523f4e1c5f8 diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md index e1ad30fd72..9983c669b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md @@ -30,4 +30,4 @@ Without registrants both seats render nothing, so the shipped page is pixel-iden ## Consequences -An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. One extension owner per adapter family is a deliberate limit — a second plugin claiming the same namespace is refused by the keyed registry rather than silently merged. +An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. Per adapter family the keyed cell renders one owner at a time: a second registration under the same namespace at the same priority is refused by the registry, while a different priority deliberately shadows it (the lowest-priority entry renders) — the slot kit's standard override channel, never a silent merge. diff --git a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md index 4e7aa7cef0..639654ea4b 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md @@ -30,4 +30,4 @@ Status: implemented ## Consequences -仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族只有一个扩展所有者是刻意的限制——第二个插件认领同一 namespace 会被 keyed 注册表拒绝,而不是被静默合并。 +仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族的 keyed 单元格同一时刻只渲染一个所有者:同一 namespace 下同 priority 的第二次注册会被注册表拒绝,不同 priority 则是刻意的遮蔽(最低 priority 的条目渲染)——这是 slot 套件的标准覆盖通道,绝非静默合并。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index cda920ea03..d0c94878b2 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee -slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0 +slots.md: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73 +slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 6eb61780ca..8e115e30ae 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -123,6 +123,8 @@ root │ ├─ settings.onboarding │ └─ settings.section │ ├─ settings.general.item +│ ├─ settings.models.provider-card +│ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item ├─ conversation diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index e5a25763d3..3894b69d8d 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -123,6 +123,8 @@ root │ ├─ settings.onboarding │ └─ settings.section │ ├─ settings.general.item +│ ├─ settings.models.provider-card +│ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item ├─ conversation diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 2e7b01899a..6776a097dd 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -53,9 +53,11 @@ type ModelsRenderSlot = PropsRenderSlots['renderSlot'] /** * Props delivered by the slot outlet: the inject face spread flat (the * renderer erases the share boundary at the render call) plus the child-slot - * dispatch seat. + * dispatch seat. The seat is required: the renderer binds it at the render + * call itself — unlike the inject face it is never absent at runtime — and a + * direct render that forgets it fails to compile instead of mounting nothing. */ -export type ModelsSectionProps = Partial> & Partial> +export type ModelsSectionProps = Partial> & PropsRenderSlots type ModelsSectionFace = InjectFace @@ -148,9 +150,17 @@ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { return row.credential?.configured !== true } -/** The provider-card seat's credential fact: a referenced key confirmed configured. */ +/** + * The provider-card seat's credential fact: the reference this page would use + * for the row — the profile's `apiKeyEnv`, or the page's derived + * `_API_KEY` while the profile names none — confirmed configured. The + * derived half is what keeps the seat consistent with the editor on the + * add-provider draft, whose dormant row names no reference yet. + */ function keyConfiguredOf(row: ProviderRow): boolean { - return row.apiKeyEnv !== undefined && row.credential?.configured === true + return row.apiKeyEnv !== undefined + ? row.credential?.configured === true + : row.derivedCredential?.configured === true } function targetOf(row: ProviderRow): EditorTarget { @@ -192,7 +202,7 @@ export function ModelsSection(props: ModelsSectionProps): ReactNode { const { controller, useSnapshot, api, schema, t, renderSlot } = props if ( controller === undefined || useSnapshot === undefined || api === undefined - || schema === undefined || t === undefined || renderSlot === undefined + || schema === undefined || t === undefined ) return null return } diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 2b17c9fc4b..df5bcb5dcd 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -32,6 +32,13 @@ export interface ProviderRow { apiKeyEnv: string | undefined /** Credential state for {@link apiKeyEnv}, once described. */ credential: CredentialView | undefined + /** + * Credential state for the page's derived `_API_KEY`, described only + * while the profile names no reference — the provider-card seat's + * `keyConfigured` fact for dormant and keyless rows, matching the editor's + * own derivation rule. + */ + derivedCredential?: CredentialView } /** Page snapshot. */ @@ -185,7 +192,7 @@ export class ModelsSettingsStore { credential: undefined, } }) - const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))] + const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { @@ -206,12 +213,15 @@ export class ModelsSettingsStore { s.error = null s.credentialError = credentialError s.writable = writable - s.rows = rows.map(row => ({ - ...row, - ...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined - ? { credential: credentials[row.apiKeyEnv] } - : {}, - })) + s.rows = rows.map((row) => { + const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] + const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] + return { + ...row, + ...named === undefined ? {} : { credential: named }, + ...derived === undefined ? {} : { derivedCredential: derived }, + } + }) s.namespaces = namespaces }) } 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 edfb6462ee..1a72d7c444 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -229,7 +229,7 @@ async function mountFace(scripted: ReturnType) { api: face as never, schema: settingsSchema, t, - renderSlot: renderSlot as unknown as NonNullable, + renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], } const view = render() return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot } @@ -293,6 +293,22 @@ describe('ModelsSection', () => { expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai']) }) + it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => { + const scripted = scriptedFace() + scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { + configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY', + writable: true, + }])), + }))) + const { renderSlot } = await mountFace(scripted) + renderSlot.mockClear() + fireEvent.click(screen.getByRole('button', { name: en.add })) + // The dormant row names no reference yet; the seat still reports the + // derived ANTHROPIC_API_KEY the editor itself displays as configured. + expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, true, 'llm-pi-ai']) + }) + it('skips the draft seat when a refresh drops the dormant row', async () => { const { renderSlot, face, controller } = await mountSection() fireEvent.click(screen.getByRole('button', { name: en.add })) @@ -415,7 +431,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) const missing = screen.getByRole('img', { name: en.credentialMissing }) @@ -440,7 +456,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) // Now a row with an Edit button, not an open card. expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1) @@ -1171,7 +1187,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) const key = await screen.findByLabelText(en.keyInput) expect(key.placeholder).toBe(en.keyPlaceholder) @@ -1311,7 +1327,7 @@ describe('ModelsSection', () => { api={face.face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) expect(screen.getByText(/directory down/)).toBeTruthy() fireEvent.click(screen.getByText(en.retry)) @@ -1334,7 +1350,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText(en.remove).every(button => button.disabled)).toBe(true) @@ -1396,7 +1412,7 @@ describe('ModelsSection', () => { api={face as never} schema={settingsSchema} t={t} - renderSlot={stubRenderSlot() as unknown as NonNullable} + renderSlot={() => null} />) await screen.findByText('DeepSeek') }) diff --git a/packages/client/ui-settings-models/tests/invariant.client.spec.ts b/packages/client/ui-settings-models/tests/invariant.client.spec.ts index ceb0b1f368..0d7a0578b7 100644 --- a/packages/client/ui-settings-models/tests/invariant.client.spec.ts +++ b/packages/client/ui-settings-models/tests/invariant.client.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { ModelsSection } from '../src/client/ModelsSection.tsx' +import type { ModelsSectionProps } from '../src/client/ModelsSection.tsx' describe('invariant companion', () => { it('registers under the package name with an empty installer', async () => { @@ -18,6 +19,6 @@ describe('invariant companion', () => { }) it('renders null until the shell injects the section dependencies', () => { - expect(ModelsSection({})).toBeNull() + expect(ModelsSection({} as ModelsSectionProps)).toBeNull() }) }) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 892a1ccb73..89c30f710b 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -672,6 +672,7 @@ describe('provider rows', () => { api={scripted.face as never} schema={settingsSchema} t={t} + renderSlot={() => null} />) // Absent is "unknown", never "shipped": an adapter that answers nothing 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 c71457e474..c477c25993 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -81,7 +81,9 @@ describe('ModelsSettingsStore', () => { expect(state.status).toBe('ready') expect(state.writable).toBe(true) expect(state.credentialError).toBeNull() - expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']]) + // Named references first (rows order), then the derived _API_KEY + // of every row whose profile names none — one batched describe. + expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GHOST_API_KEY']]) const byProvider = new Map(state.rows.map(row => [row.entry.provider, row])) expect(byProvider.get('deepseek-official')).toMatchObject({ configured: true, @@ -196,7 +198,7 @@ describe('edge joins', () => { expect(state.rows[0]?.apiKeyEnv).toBeUndefined() }) - it('skips the credential describe entirely when no row names a reference', async () => { + it('describes the derived reference for a row whose profile names none', async () => { const { face, mirror, seenRefs } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, @@ -208,11 +210,19 @@ describe('edge joins', () => { { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, ] as never, })), + describeCredentials: refs => Promise.resolve(ok({ + credentials: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), + })), }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() - expect(seenRefs).toEqual([]) - expect(store.store.getSnapshot().status).toBe('ready') + // The dormant row names no reference, so the join asks about the page's + // own derived _API_KEY — what the editor would display for it. + expect(seenRefs).toEqual([['ANTHROPIC_API_KEY']]) + const state = store.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.rows[0]?.credential).toBeUndefined() + expect(state.rows[0]?.derivedCredential).toMatchObject({ configured: true }) }) it('surfaces a settings describe failure', async () => {