mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(ui-settings-models): address review — derived key fact, required render seat, spec sync
The provider-card seat's keyConfigured now derives from the reference the
page would use — the profile's apiKeyEnv, or the page's derived
<ROUTE>_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.
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 套件的标准覆盖通道,绝非静默合并。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,9 +53,11 @@ type ModelsRenderSlot = PropsRenderSlots<ModelsChildSlots>['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<InjectFace<ModelsSectionInjected>> & Partial<PropsRenderSlots<ModelsChildSlots>>
|
||||
export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>> & PropsRenderSlots<ModelsChildSlots>
|
||||
|
||||
type ModelsSectionFace = InjectFace<ModelsSectionInjected>
|
||||
|
||||
@@ -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
|
||||
* `<ROUTE>_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 <Loaded injected={{ controller, useSnapshot, api, schema, t }} renderSlot={renderSlot} />
|
||||
}
|
||||
|
||||
@@ -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 `<ROUTE>_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<string, CredentialView> = {}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
api: face as never,
|
||||
schema: settingsSchema,
|
||||
t,
|
||||
renderSlot: renderSlot as unknown as NonNullable<ModelsSectionProps['renderSlot']>,
|
||||
renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
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<ModelsSectionProps['renderSlot']>}
|
||||
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<ModelsSectionProps['renderSlot']>}
|
||||
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<ModelsSectionProps['renderSlot']>}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(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<ModelsSectionProps['renderSlot']>}
|
||||
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<ModelsSectionProps['renderSlot']>}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(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<ModelsSectionProps['renderSlot']>}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
await screen.findByText('DeepSeek')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <ROUTE>_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 <ROUTE>_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 () => {
|
||||
|
||||
Reference in New Issue
Block a user