DSH / Atlas
2026-08-26implementedarchitecturearchived 2026-09-04

Models-page extension slots

Models 页扩展插槽

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 p

English

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. 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.

中文

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)成为扩展方依赖的行为。每个适配器家族的 keyed 单元格同一时刻只渲染一个所有者:同一 namespace 下同 priority 的第二次注册会被注册表拒绝,不同 priority 则是刻意的遮蔽(最低 priority 的条目渲染)——这是 slot 套件的标准覆盖通道,绝非静默合并。