feat(settings): serve every registered namespace and key plugin cards on it

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.
This commit is contained in:
Yichen Jiang
2026-08-12 21:07:57 +08:00
parent 4bc526f40b
commit 4366528a38
36 changed files with 745 additions and 197 deletions

View File

@@ -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/architecture.md
architecture.md: f5ebff879929079870c7936b424f03a02089c9d5
architecture.zh.md: 1769f6febc4f156f6abccc5a19363f6eb55b6139
architecture.md: 2e5e8dfdc0f6b66bb62f36c0276b9ab8d5af9973
architecture.zh.md: 1b3da0d0dfebcb4cd4c57af307958189416561e5

View File

@@ -197,4 +197,4 @@ New behavior attaches to a documented extension point; a loop change updates thi
| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) |
[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), [settings cards](cookbook/adding-a-settings-card.md), and [vendored packages](cookbook/adding-a-vendored-package.md).

View File

@@ -197,4 +197,4 @@ idle inject:
| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) |
[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。
[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)、[设置卡片](cookbook/adding-a-settings-card.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。

View File

@@ -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 docs/cookbook/adding-a-settings-card.md
adding-a-settings-card.md: fa5524412e885677d6534702b16787abc135b30d
adding-a-settings-card.zh.md: 9670f815dd23e7f48e12902ca93fd436114431f0

View File

@@ -0,0 +1,100 @@
# 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).

View File

@@ -0,0 +1,100 @@
# Cookbook: 新增设置卡片
[English](adding-a-settings-card.md) | 中文
插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。
两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-plugin-config`](../../packages/client/ui-plugin-config)。
## 1. 注册命名空间Host 半侧)
命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `installSettingsSection` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作:
```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')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。
## 2. 注册卡片(浏览器半侧)
卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅:
```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),
)
}
```
scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。
## 3. 分区拿它做什么
分区读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本页之外的。
卡片按其注册进该 slot 的顺序出现keyed entry 不声明自己的 `order`。
## 打包
浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。
```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"] } }
}
```
bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行:
```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'])
```
该预设目前未发布因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-plugin-config/README.md#known-limitations-and-deferred-work)里。