A plugin that registered a settings namespace could not reach the browser configuration page: the api-proxy filtered every read and gated every write through two hardcoded namespace lists, and the plugin configuration section rendered an unordered list of cards carrying an opaque id rather than the namespace they edit. Both gates lived in this repository, so a user-authored plugin was configurable only by hand-editing settings.yaml. The proxy now serves whatever ctx.settings.describe() returns and adds no boundary of its own; a name no registration answers folds into the seam's own settings-rejected, and the settings-not-exposed code retires. The settings seam is untouched: which client may read a namespace, and which page renders it, are facts about consumers. settings.plugin.item becomes a keyed slot whose key is the namespace a card edits, following tool.call.toolview. The section reads describe once and dispatches the intersection of the slot ledger and the served set, so a namespace another surface owns renders nothing without declaring anything, and a card for an uncomposed plugin is never dispatched.
101 lines
5.6 KiB
Markdown
101 lines
5.6 KiB
Markdown
# Cookbook: adding a settings card
|
|
|
|
English | [中文](adding-a-settings-card.zh.md)
|
|
|
|
How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically.
|
|
|
|
The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-plugin-config`](../../packages/client/ui-plugin-config).
|
|
|
|
## 1. Register the namespace (Host half)
|
|
|
|
The namespace is the join key, so pick it once and spell it in both halves. A consumer that already has a `cordis.yml` entry should register through `installSettingsSection`, which layers the entry under the user document and keeps working when no settings provider is mounted:
|
|
|
|
```ts
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import z from '@deepseek-ai/schemastery'
|
|
|
|
declare function assertReachable(endpoint: string | undefined): void
|
|
declare function rebuildFromSettings(config: Config): void
|
|
|
|
export const MY_PLUGIN_NS = settingsNamespace('my-plugin')
|
|
|
|
export interface Config {
|
|
endpoint?: string
|
|
retries?: number
|
|
}
|
|
|
|
export const Config: z<Config> = z.object({
|
|
endpoint: z.string(),
|
|
retries: z.number().step(1).min(0).default(3),
|
|
})
|
|
|
|
export function apply(ctx: Context, config: Config) {
|
|
let source = () => config
|
|
installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, {
|
|
// Constraints the schema cannot express refuse the write, not the next use.
|
|
validate: value => void assertReachable(value.endpoint),
|
|
setSource: (current) => { source = current },
|
|
onChange: () => { rebuildFromSettings(source()) },
|
|
})
|
|
}
|
|
```
|
|
|
|
`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start.
|
|
|
|
## 2. Register the card (browser half)
|
|
|
|
The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read:
|
|
|
|
```ts ignore-check
|
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes
|
|
// through cordis services; a value import fails the client bundle-purity gate.
|
|
import type {} from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
|
|
|
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
|
|
|
export function apply(ctx: ClientContext): void {
|
|
const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' }))
|
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
name: 'settings.plugin.item',
|
|
key: 'my-plugin',
|
|
locale: 'settings.myPlugin',
|
|
inject: () => card.inject(),
|
|
}, MyPluginCard),
|
|
)
|
|
}
|
|
```
|
|
|
|
The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer.
|
|
|
|
## 3. What the section does with it
|
|
|
|
The section reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this page.
|
|
|
|
Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own.
|
|
|
|
## Packaging
|
|
|
|
The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application.
|
|
|
|
```jsonc
|
|
{
|
|
"exports": {
|
|
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
|
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
|
},
|
|
"dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-plugin-config"] } }
|
|
}
|
|
```
|
|
|
|
The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset:
|
|
|
|
```ts ignore-check
|
|
import { clientBundle } from '../tsdown.client.ts'
|
|
|
|
export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js'])
|
|
```
|
|
|
|
That preset is not published today, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-plugin-config/README.md#known-limitations-and-deferred-work).
|