fix(client-ui-plugin-config): refresh the key badge when the Host reports the credential changed
The card read the credential only when its settings scope published, and a credential is not part of any settings section: a key written from the Models page — which addresses the same reference — left this badge reporting a state the Host had already replaced. It now re-reads on credentials/changed for the reference it watches, and ignores the event for any other reference.
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
|
||||||
README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30
|
README.md: cd5ea70e7ca8e126418b73447c7ef2f1aed94b4d
|
||||||
README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41
|
README.zh.md: 2859fa9b92d622ff36eb71f099289509fcd84fec
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ A card stages what the user types and writes it only when they save. Each contro
|
|||||||
|
|
||||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||||
|
|
||||||
|
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the Host's credential-changed signal for the reference it watches.
|
||||||
|
|
||||||
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
|
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||||
|
|
||||||
|
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在 Host 报告它所关注的引用发生变化时重读。
|
||||||
|
|
||||||
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
|
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export function apply(ctx: ClientContext): void {
|
|||||||
const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS }))
|
const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS }))
|
||||||
const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api)
|
const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api)
|
||||||
|
|
||||||
|
// The credential a card reports is not part of any settings section, so its
|
||||||
|
// scope publishes nothing when one is written. This is the only signal that
|
||||||
|
// a key written on another surface reached the Host.
|
||||||
|
ctx.effect(
|
||||||
|
() => ctx.on('credentials/changed', (ref) => { webSearch.refreshCredential(ref) }),
|
||||||
|
'ui-plugin-config: credential invalidations',
|
||||||
|
)
|
||||||
|
|
||||||
// The section renders the empty line rather than an empty list when no plugin
|
// The section renders the empty line rather than an empty list when no plugin
|
||||||
// contributed a card. The count is read once: the renderer caches a root
|
// contributed a card. The count is read once: the renderer caches a root
|
||||||
// entry's inject face per registration, so this reports what was registered
|
// entry's inject face per registration, so this reports what was registered
|
||||||
|
|||||||
@@ -143,6 +143,19 @@ export class WebSearchCardController {
|
|||||||
this.store.set(this.projection())
|
this.store.set(this.projection())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read after the Host reports a change to the reference this card watches.
|
||||||
|
*
|
||||||
|
* A key can be written from somewhere else — the Models page addresses the
|
||||||
|
* same reference — and the settings section does not change when it is, so
|
||||||
|
* without this the badge keeps reporting a state the Host already replaced.
|
||||||
|
* @param ref - the reference the Host reports as changed.
|
||||||
|
*/
|
||||||
|
refreshCredential(ref: string): void {
|
||||||
|
if (ref !== this.credential.ref) return
|
||||||
|
void this.readCredential()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the face the card's slot registration injects.
|
* Build the face the card's slot registration injects.
|
||||||
* @returns the card's snapshot and its form actions.
|
* @returns the card's snapshot and its form actions.
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ async function bench() {
|
|||||||
await ctx.plugin(SlotsService).await()
|
await ctx.plugin(SlotsService).await()
|
||||||
const locale = new LocaleService(ctx)
|
const locale = new LocaleService(ctx)
|
||||||
ctx.provide('locale', locale)
|
ctx.provide('locale', locale)
|
||||||
|
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
|
||||||
ctx.provide('connection', {
|
ctx.provide('connection', {
|
||||||
isLoopback: true,
|
isLoopback: true,
|
||||||
api: {
|
api: {
|
||||||
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
||||||
credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) },
|
credentials: { describe: describeCredentials },
|
||||||
},
|
},
|
||||||
} as never)
|
} as never)
|
||||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
|
||||||
}
|
}
|
||||||
|
|
||||||
function declareRoot(slots: SlotsService): () => void {
|
function declareRoot(slots: SlotsService): () => void {
|
||||||
@@ -76,6 +77,33 @@ describe('ui-plugin-config apply', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('re-reads the credential when the Host reports the watched reference changed', async () => {
|
||||||
|
const { ctx, slots, describeCredentials } = await bench()
|
||||||
|
declareRoot(slots)
|
||||||
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
|
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||||
|
describeCredentials.mockClear()
|
||||||
|
|
||||||
|
// A key written on another surface changes no settings section, so this
|
||||||
|
// event is the only thing that reaches the card.
|
||||||
|
ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
|
||||||
|
|
||||||
|
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a credential change for a reference no card watches', async () => {
|
||||||
|
const { ctx, slots, describeCredentials } = await bench()
|
||||||
|
declareRoot(slots)
|
||||||
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
|
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||||
|
describeCredentials.mockClear()
|
||||||
|
|
||||||
|
ctx.emit('credentials/changed', 'SOME_OTHER_KEY')
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(describeCredentials).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('registers into a declaration that arrives after apply', async () => {
|
it('registers into a declaration that arrives after apply', async () => {
|
||||||
const { ctx, slots } = await bench()
|
const { ctx, slots } = await bench()
|
||||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
|
|||||||
@@ -436,6 +436,30 @@ describe('WebSearchCardController', () => {
|
|||||||
expect(credentials.set).not.toHaveBeenCalled()
|
expect(credentials.set).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('re-reads when the Host reports the watched reference changed', async () => {
|
||||||
|
const host = stubSettingsScope<WebSearchSettings>()
|
||||||
|
const credentials = credentialsApi(false)
|
||||||
|
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||||
|
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||||
|
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||||
|
credentials.describe.mockClear()
|
||||||
|
|
||||||
|
// Another reference is not this card's business.
|
||||||
|
controller.refreshCredential('OTHER_KEY')
|
||||||
|
expect(credentials.describe).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// A key written on another surface reaches this card only through this signal.
|
||||||
|
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||||
|
rpcId: 'c-1' as never,
|
||||||
|
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||||
|
}))
|
||||||
|
controller.refreshCredential('DEEPSEEK_API_KEY')
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('addresses the reference the section declares rather than the default', async () => {
|
it('addresses the reference the section declares rather than the default', async () => {
|
||||||
const host = stubSettingsScope<WebSearchSettings>()
|
const host = stubSettingsScope<WebSearchSettings>()
|
||||||
const credentials = credentialsApi(false)
|
const credentials = credentialsApi(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user