mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(client): configure host-plane plugins from a settings section
The section knows no namespace: it declares `settings.plugin.item` and renders whatever cards were registered into it, so a plugin that ships a browser half owns its card and its controls. The three cards here cover the host-plane sections this deployment exposes. A field shows its effective value and, when the raw user layer carries it, an override badge and a reset that clears it back to the composition layer. Controls commit on blur and Enter rather than per keystroke, which would burn namespace revisions and race its own reads. The search key is the one value that never rides a response: the card reports only whether one is configured and writes it through the credentials domain, addressed by the reference the section names. A card renders nothing while its namespace is unavailable — a deployment that does not compose the owning plugin should show no trace of it rather than a disabled card the user cannot act on.
This commit is contained in:
@@ -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/config-catalog.md
|
||||
config-catalog.md: 36053fd205923e207224a01f3f948c1977004c1c
|
||||
config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287
|
||||
config-catalog.md: a5269c1c5bbf45cdd6e13a9b4c9e6cb57dab1db9
|
||||
config-catalog.zh.md: 7cff05d3f741b40689db7ece92b0739e114a5206
|
||||
|
||||
@@ -2717,6 +2717,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
|
||||
|
||||
@@ -2718,6 +2718,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-client-ui-models`([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
|
||||
|
||||
@@ -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/module-graph.md
|
||||
module-graph.md: a9d4def424c875860781b4b7d46aea2e9af903dd
|
||||
module-graph.zh.md: 759ded070a2991689ee18ca679cfa57213abc49d
|
||||
module-graph.md: e6908727593755d52353e5119f6d57919f3d83f4
|
||||
module-graph.zh.md: bd7fe1e7212019168554367e505aca99eba97d67
|
||||
|
||||
@@ -157,6 +157,7 @@ flowchart TD
|
||||
pkg_client_ui_models["client-ui-models"]
|
||||
pkg_client_ui_permission["client-ui-permission"]
|
||||
pkg_client_ui_plan["client-ui-plan"]
|
||||
pkg_client_ui_plugin_config["client-ui-plugin-config"]
|
||||
pkg_client_ui_primitives["client-ui-primitives"]
|
||||
pkg_client_ui_question["client-ui-question"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
@@ -432,6 +433,14 @@ flowchart TD
|
||||
pkg_skill --> pkg_scope
|
||||
pkg_web --> pkg_invariants
|
||||
pkg_web --> pkg_llm
|
||||
pkg_client_ui_plugin_config --> pkg_client_connection
|
||||
pkg_client_ui_plugin_config --> pkg_client_locale
|
||||
pkg_client_ui_plugin_config --> pkg_client_runtime
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_primitives
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_settings
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_slots
|
||||
pkg_client_ui_plugin_config --> pkg_client_web_react
|
||||
pkg_client_ui_plugin_config --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_connection
|
||||
@@ -1303,6 +1312,7 @@ flowchart TD
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
|
||||
@@ -159,6 +159,7 @@ flowchart TD
|
||||
pkg_client_ui_models["client-ui-models"]
|
||||
pkg_client_ui_permission["client-ui-permission"]
|
||||
pkg_client_ui_plan["client-ui-plan"]
|
||||
pkg_client_ui_plugin_config["client-ui-plugin-config"]
|
||||
pkg_client_ui_primitives["client-ui-primitives"]
|
||||
pkg_client_ui_question["client-ui-question"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
@@ -434,6 +435,14 @@ flowchart TD
|
||||
pkg_skill --> pkg_scope
|
||||
pkg_web --> pkg_invariants
|
||||
pkg_web --> pkg_llm
|
||||
pkg_client_ui_plugin_config --> pkg_client_connection
|
||||
pkg_client_ui_plugin_config --> pkg_client_locale
|
||||
pkg_client_ui_plugin_config --> pkg_client_runtime
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_primitives
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_settings
|
||||
pkg_client_ui_plugin_config --> pkg_client_ui_slots
|
||||
pkg_client_ui_plugin_config --> pkg_client_web_react
|
||||
pkg_client_ui_plugin_config --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_connection
|
||||
@@ -1305,6 +1314,7 @@ flowchart TD
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
|
||||
@@ -188,6 +188,11 @@
|
||||
- id: ui-agent-preset
|
||||
name: '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
# Plugin configuration: the host-plane sections a user owns, as expandable
|
||||
# cards. A namespace this deployment does not expose renders nothing.
|
||||
- id: ui-plugin-config
|
||||
name: '@deepseek-ai/dsh-client-ui-plugin-config'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
@@ -50,6 +49,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
|
||||
@@ -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 packages/client/README.md
|
||||
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
|
||||
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
|
||||
README.md: 2518285cea1dfd022a3d656bd4b7a7f2bd77a08e
|
||||
README.zh.md: 962326f055866119370ff9ad845fed5103928541
|
||||
|
||||
@@ -32,6 +32,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
|
||||
@@ -32,6 +32,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
|
||||
6
packages/client/ui-plugin-config/README.i18n.yaml
Normal file
6
packages/client/ui-plugin-config/README.i18n.yaml
Normal 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 packages/client/ui-plugin-config/README.md
|
||||
README.md: e830589b5e279fb0bdda23591502989bebc2a336
|
||||
README.zh.md: e614d7b6858f8cbf2a38cb7397b5d8f93445ab6c
|
||||
33
packages/client/ui-plugin-config/README.md
Normal file
33
packages/client/ui-plugin-config/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# dsh-client-ui-plugin-config
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
|
||||
## What appears here
|
||||
|
||||
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
|
||||
|
||||
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
## Extension point
|
||||
|
||||
The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
|
||||
|
||||
## Writes
|
||||
|
||||
Every control writes one field through the client settings scope, which fences each write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. 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 reports only whether one is configured and writes through the credentials domain rather than the settings section.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
|
||||
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card.
|
||||
33
packages/client/ui-plugin-config/README.zh.md
Normal file
33
packages/client/ui-plugin-config/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# dsh-client-ui-plugin-config
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
|
||||
## 这里会出现什么
|
||||
|
||||
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
|
||||
|
||||
第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。
|
||||
|
||||
## 写入
|
||||
|
||||
每个控件都通过客户端 settings scope 写入单个字段,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件只报告是否已配置,并经由 credentials 领域而非 settings 分节写入。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该分区渲染浏览器配置 UI;它写入的值只通过拥有这些值的插件到达模型,而这些效应各由其拥有方的包记录。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
|
||||
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。
|
||||
71
packages/client/ui-plugin-config/package.json
Normal file
71
packages/client/ui-plugin-config/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
|
||||
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** The agent-loop plugin's card: how many tool calls may run at once. */
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NumberField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { AgentLoopCardState } from './agent-loop-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the agent-loop card. */
|
||||
export interface AgentLoopCardInjected {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useAgentLoopCard. */
|
||||
agentLoopCard: SnapshotStore<AgentLoopCardState>
|
||||
}
|
||||
/** Write the parallel tool-call cap. */
|
||||
setMaxParallelToolCalls: (next: number) => void
|
||||
/** Clear the cap so it re-inherits the composition layer. */
|
||||
resetMaxParallelToolCalls: () => void
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the agent-loop card. */
|
||||
export type AgentLoopCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<AgentLoopCardInjected>
|
||||
|
||||
/**
|
||||
* Render the agent-loop card.
|
||||
* @param props - locale copy, the card snapshot, and its write actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function AgentLoopCard(props: AgentLoopCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useAgentLoopCard(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
title={t('agentLoopTitle')}
|
||||
description={t('agentLoopDescription')}
|
||||
available={state.available}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open) }}
|
||||
readOnly={disabled}
|
||||
readOnlyLabel={t('readOnly')}
|
||||
>
|
||||
<NumberField
|
||||
id="plugin-config-agent-loop-parallel"
|
||||
label={t('agentLoopMaxParallel')}
|
||||
hint={t('agentLoopMaxParallelHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
overridden={state.maxParallelToolCalls.overridden}
|
||||
disabled={disabled}
|
||||
value={state.maxParallelToolCalls.value}
|
||||
onCommit={props.setMaxParallelToolCalls}
|
||||
onReset={props.resetMaxParallelToolCalls}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
79
packages/client/ui-plugin-config/src/client/BashCard.tsx
Normal file
79
packages/client/ui-plugin-config/src/client/BashCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/** The shell plugin's card: the limits every command the agent runs is bound by. */
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NumberField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { BashCardState } from './bash-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the shell card. */
|
||||
export interface BashCardInjected {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useBashCard. */
|
||||
bashCard: SnapshotStore<BashCardState>
|
||||
}
|
||||
/** Write the foreground command timeout. */
|
||||
setTimeoutMs: (next: number) => void
|
||||
/** Clear the timeout so it re-inherits the composition layer. */
|
||||
resetTimeoutMs: () => void
|
||||
/** Write the per-stream output cap. */
|
||||
setMaxOutputBytes: (next: number) => void
|
||||
/** Clear the output cap so it re-inherits the composition layer. */
|
||||
resetMaxOutputBytes: () => void
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the shell card. */
|
||||
export type BashCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<BashCardInjected>
|
||||
|
||||
/**
|
||||
* Render the shell card.
|
||||
* @param props - locale copy, the card snapshot, and its write actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function BashCard(props: BashCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useBashCard(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
title={t('bashTitle')}
|
||||
description={t('bashDescription')}
|
||||
available={state.available}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open) }}
|
||||
readOnly={disabled}
|
||||
readOnlyLabel={t('readOnly')}
|
||||
>
|
||||
<NumberField
|
||||
id="plugin-config-bash-timeout"
|
||||
label={t('bashTimeoutMs')}
|
||||
hint={t('bashTimeoutMsHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
overridden={state.timeoutMs.overridden}
|
||||
disabled={disabled}
|
||||
value={state.timeoutMs.value}
|
||||
onCommit={props.setTimeoutMs}
|
||||
onReset={props.resetTimeoutMs}
|
||||
/>
|
||||
<NumberField
|
||||
id="plugin-config-bash-output"
|
||||
label={t('bashMaxOutputBytes')}
|
||||
hint={t('bashMaxOutputBytesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
overridden={state.maxOutputBytes.overridden}
|
||||
disabled={disabled}
|
||||
value={state.maxOutputBytes.value}
|
||||
onCommit={props.setMaxOutputBytes}
|
||||
onReset={props.resetMaxOutputBytes}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Plugin card: one expandable row per plugin, its body holding the controls. */
|
||||
|
||||
.card {
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 0 8px 24px;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
61
packages/client/ui-plugin-config/src/client/PluginCard.tsx
Normal file
61
packages/client/ui-plugin-config/src/client/PluginCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* One plugin's card: an expandable row whose body is that plugin's controls.
|
||||
* A card renders nothing while its namespace is unavailable — a deployment
|
||||
* that does not compose the owning plugin should show no trace of it, rather
|
||||
* than an empty or disabled card the user cannot act on.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './PluginCard.module.css'
|
||||
|
||||
/** Card chrome shared by every plugin section. */
|
||||
export interface PluginCardProps {
|
||||
/** Plugin name shown on the row. */
|
||||
title: string
|
||||
/** One line describing what this plugin's settings govern. */
|
||||
description: string
|
||||
/** False while the namespace is not served to this client. */
|
||||
available: boolean
|
||||
/** Whether the card body is showing. */
|
||||
open: boolean
|
||||
/** Toggle the card body. */
|
||||
onToggle: () => void
|
||||
/** Copy shown when the settings document refuses writes. */
|
||||
readOnlyLabel?: string | undefined
|
||||
/** True when the Host document is read-only. */
|
||||
readOnly: boolean
|
||||
/** The plugin's controls. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one plugin card.
|
||||
* @param props - card chrome, disclosure state, and the plugin's controls.
|
||||
* @returns the card, or nothing when the namespace is unavailable.
|
||||
*/
|
||||
export function PluginCard(props: PluginCardProps) {
|
||||
if (!props.available) return null
|
||||
return (
|
||||
<li className={css.card}>
|
||||
<DisclosureRow
|
||||
icon={null}
|
||||
title={props.title}
|
||||
open={props.open}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
onToggle={props.onToggle}
|
||||
rowClassName={css.row}
|
||||
titleClassName={css.title}
|
||||
collapsedContent={<span className={css.description}>{props.description}</span>}
|
||||
>
|
||||
<div className={css.body}>
|
||||
{props.readOnly && props.readOnlyLabel !== undefined
|
||||
? <p className={css.readOnly} role="status">{props.readOnlyLabel}</p>
|
||||
: null}
|
||||
{props.children}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Plugin configuration section: heading, intro, and the card list. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 8px 0 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Plugin configuration section: the shell around the per-plugin cards. It
|
||||
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
|
||||
* slot it declares, so a plugin that ships a browser half owns its own card
|
||||
* and this section never learns what a namespace means.
|
||||
*/
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import type { PluginConfigKey } from './locales.ts'
|
||||
import css from './PluginConfigSection.module.css'
|
||||
|
||||
/** Registration-side business face for the section. */
|
||||
export interface PluginConfigSectionInjected {
|
||||
/** How many cards the slot ledger currently holds; zero renders the empty line. */
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the section. */
|
||||
export type PluginConfigSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& InjectFace<PluginConfigSectionInjected>
|
||||
|
||||
/**
|
||||
* Render the plugin configuration section.
|
||||
* @param props - runtime slot rendering, locale copy, and the card count.
|
||||
* @returns the section.
|
||||
*/
|
||||
export function PluginConfigSection(props: PluginConfigSectionProps) {
|
||||
const { t, renderSlot, cardCount } = props
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.heading}>{t('title')}</h2>
|
||||
<p className={css.intro}>{t('intro')}</p>
|
||||
{cardCount === 0
|
||||
? <p className={css.empty}>{t('empty')}</p>
|
||||
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Plugin configuration section and card copy. */
|
||||
'settings.pluginConfig': PluginConfigKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The web-search provider's card: its endpoint, its per-request search budget,
|
||||
* and the key — which is written through the credentials domain, never into
|
||||
* the settings section, so the literal never rides a response.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NumberField, SecretField, TextField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { WebSearchCardState } from './web-search-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the web-search card. */
|
||||
export interface WebSearchCardInjected {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
/** Write the provider endpoint; the empty string clears it. */
|
||||
setBaseUrl: (next: string) => void
|
||||
/** Clear the endpoint so it re-inherits the composition layer. */
|
||||
resetBaseUrl: () => void
|
||||
/** Write the per-request search budget. */
|
||||
setMaxUses: (next: number) => void
|
||||
/** Clear the budget so it re-inherits the composition layer. */
|
||||
resetMaxUses: () => void
|
||||
/** Write the credential the section references. */
|
||||
setApiKey: (next: string) => void
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the web-search card. */
|
||||
export type WebSearchCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<WebSearchCardInjected>
|
||||
|
||||
/**
|
||||
* Render the web-search card.
|
||||
* @param props - locale copy, the card snapshot, and its write actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function WebSearchCard(props: WebSearchCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useWebSearchCard(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
title={t('webSearchTitle')}
|
||||
description={t('webSearchDescription')}
|
||||
available={state.available}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open) }}
|
||||
readOnly={disabled}
|
||||
readOnlyLabel={t('readOnly')}
|
||||
>
|
||||
<SecretField
|
||||
id="plugin-config-web-search-key"
|
||||
label={t('webSearchApiKey')}
|
||||
hint={t('webSearchApiKeyHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals.
|
||||
disabled={false}
|
||||
configured={state.apiKeyConfigured}
|
||||
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
|
||||
onCommit={props.setApiKey}
|
||||
/>
|
||||
<TextField
|
||||
id="plugin-config-web-search-endpoint"
|
||||
label={t('webSearchBaseUrl')}
|
||||
hint={t('webSearchBaseUrlHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
overridden={state.baseURL.overridden}
|
||||
disabled={disabled}
|
||||
value={state.baseURL.value}
|
||||
onCommit={props.setBaseUrl}
|
||||
onReset={props.resetBaseUrl}
|
||||
/>
|
||||
<NumberField
|
||||
id="plugin-config-web-search-max-uses"
|
||||
label={t('webSearchMaxUses')}
|
||||
hint={t('webSearchMaxUsesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
overridden={state.maxUses.overridden}
|
||||
disabled={disabled}
|
||||
value={state.maxUses.value}
|
||||
onCommit={props.setMaxUses}
|
||||
onReset={props.resetMaxUses}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** The agent-loop card's state and writes over the `agent-loop` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the agent loop's user-owned settings. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const AGENT_LOOP_NS = 'agent-loop'
|
||||
|
||||
/**
|
||||
* The agent-loop fields this card edits. The Host section carries only this
|
||||
* field — the composed `agents` array is deliberately not part of it.
|
||||
*/
|
||||
export interface AgentLoopSettings {
|
||||
/** Upper bound on parallel-safe tool calls in flight per step. */
|
||||
maxParallelToolCalls?: number
|
||||
}
|
||||
|
||||
/** What the agent-loop card renders. */
|
||||
export interface AgentLoopCardState extends CardShell {
|
||||
/** Parallel tool-call cap. */
|
||||
maxParallelToolCalls: CardField<number>
|
||||
}
|
||||
|
||||
/** The registration-side face the agent-loop card's slot entry injects. */
|
||||
export interface AgentLoopCardFace {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useAgentLoopCard. */
|
||||
agentLoopCard: SnapshotStore<AgentLoopCardState>
|
||||
}
|
||||
/** Write the parallel tool-call cap. */
|
||||
setMaxParallelToolCalls: (next: number) => void
|
||||
/** Clear the cap so it re-inherits the composition layer. */
|
||||
resetMaxParallelToolCalls: () => void
|
||||
}
|
||||
|
||||
/** Bridges the `agent-loop` scope onto the card's state and writes. */
|
||||
export class AgentLoopCardController extends CardController<AgentLoopSettings, AgentLoopCardState> {
|
||||
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
|
||||
constructor(scope: SettingsScope<AgentLoopSettings>) {
|
||||
super(scope, snapshot => ({
|
||||
...shellOf(snapshot),
|
||||
maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its write actions.
|
||||
*/
|
||||
inject(): AgentLoopCardFace {
|
||||
return {
|
||||
hooks: { agentLoopCard: this.store },
|
||||
setMaxParallelToolCalls: (next: number) => { void this.scope.set('maxParallelToolCalls', next) },
|
||||
resetMaxParallelToolCalls: () => { void this.scope.unset('maxParallelToolCalls') },
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/client/ui-plugin-config/src/client/bash-store.ts
Normal file
71
packages/client/ui-plugin-config/src/client/bash-store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/** The shell card's state and writes over the `bash` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the shell capability. Spelled here rather than imported: a
|
||||
* client package must not depend on a Host package, and the executor families
|
||||
* that own it spell the same value.
|
||||
*/
|
||||
export const BASH_NS = 'bash'
|
||||
|
||||
/** The shell fields this card edits — a subset of the served schema by design. */
|
||||
export interface BashSettings {
|
||||
/** Foreground command timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Per-stream in-memory output cap in bytes. */
|
||||
maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** What the shell card renders. */
|
||||
export interface BashCardState extends CardShell {
|
||||
/** Command timeout in milliseconds. */
|
||||
timeoutMs: CardField<number>
|
||||
/** Per-stream output cap in bytes. */
|
||||
maxOutputBytes: CardField<number>
|
||||
}
|
||||
|
||||
/** The registration-side face the shell card's slot entry injects. */
|
||||
export interface BashCardFace {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useBashCard. */
|
||||
bashCard: SnapshotStore<BashCardState>
|
||||
}
|
||||
/** Write the foreground command timeout. */
|
||||
setTimeoutMs: (next: number) => void
|
||||
/** Clear the timeout so it re-inherits the composition layer. */
|
||||
resetTimeoutMs: () => void
|
||||
/** Write the per-stream output cap. */
|
||||
setMaxOutputBytes: (next: number) => void
|
||||
/** Clear the output cap so it re-inherits the composition layer. */
|
||||
resetMaxOutputBytes: () => void
|
||||
}
|
||||
|
||||
/** Bridges the `bash` scope onto the shell card's state and writes. */
|
||||
export class BashCardController extends CardController<BashSettings, BashCardState> {
|
||||
/** @param scope - the bound settings scope for the `bash` namespace. */
|
||||
constructor(scope: SettingsScope<BashSettings>) {
|
||||
super(scope, snapshot => ({
|
||||
...shellOf(snapshot),
|
||||
// The fallbacks only show before the Host serves a section; every served
|
||||
// section is already schema-defaulted by the owning executor.
|
||||
timeoutMs: fieldOf(snapshot, 'timeoutMs', 0),
|
||||
maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its write actions.
|
||||
*/
|
||||
inject(): BashCardFace {
|
||||
return {
|
||||
hooks: { bashCard: this.store },
|
||||
setTimeoutMs: (next: number) => { void this.scope.set('timeoutMs', next) },
|
||||
resetTimeoutMs: () => { void this.scope.unset('timeoutMs') },
|
||||
setMaxOutputBytes: (next: number) => { void this.scope.set('maxOutputBytes', next) },
|
||||
resetMaxOutputBytes: () => { void this.scope.unset('maxOutputBytes') },
|
||||
}
|
||||
}
|
||||
}
|
||||
84
packages/client/ui-plugin-config/src/client/card-store.ts
Normal file
84
packages/client/ui-plugin-config/src/client/card-store.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared projection from one settings scope onto a card's fields.
|
||||
*
|
||||
* A card shows the effective value of each field and whether the user set it.
|
||||
* Both come from the scope snapshot: `value` is what the plugin resolves, and
|
||||
* the presence of a key in the raw `user` layer is what makes it overridden —
|
||||
* an override equal to the composition default is still an override, and
|
||||
* comparing values could not tell them apart.
|
||||
*/
|
||||
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One field as a card renders it. */
|
||||
export interface CardField<V> {
|
||||
/** Effective value: the user layer over the composition layer over the schema default. */
|
||||
value: V
|
||||
/** Whether the raw user layer carries this field. */
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
/** State every plugin card shares. */
|
||||
export interface CardShell {
|
||||
/** False while the namespace is not served to this client; the card renders nothing. */
|
||||
available: boolean
|
||||
/** Whether the Host document accepts writes. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one field out of a scope snapshot.
|
||||
* @param snapshot - the scope snapshot to project.
|
||||
* @param field - the section field to read.
|
||||
* @param fallback - value shown before the Host serves a section.
|
||||
* @returns the field as a card renders it.
|
||||
*/
|
||||
export function fieldOf<T, V>(
|
||||
snapshot: SettingsScopeSnapshot<T>,
|
||||
field: string,
|
||||
fallback: V,
|
||||
): CardField<V> {
|
||||
const section = snapshot.value as Record<string, unknown> | undefined
|
||||
const user = snapshot.user as Record<string, unknown> | undefined
|
||||
const value = section?.[field]
|
||||
return {
|
||||
value: value === undefined ? fallback : value as V,
|
||||
overridden: user !== undefined && Object.hasOwn(user, field),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the shell every card shares.
|
||||
* @param snapshot - the scope snapshot to project.
|
||||
* @returns availability and writability.
|
||||
*/
|
||||
export function shellOf<T>(snapshot: SettingsScopeSnapshot<T>): CardShell {
|
||||
return { available: snapshot.status === 'ready', writable: snapshot.writable }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a snapshot store synchronized with one settings scope.
|
||||
*
|
||||
* The store exists because slot components read through a snapshot selector,
|
||||
* while the scope publishes its own snapshot; this bridges the two and gives
|
||||
* each card a state shaped for rendering rather than for the wire.
|
||||
*/
|
||||
export class CardController<T, S> {
|
||||
/** Snapshot the card's component reads through its bound selector. */
|
||||
readonly store: SnapshotStore<S>
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for this card's namespace.
|
||||
* @param project - build the card state from a scope snapshot.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly scope: SettingsScope<T>,
|
||||
private readonly project: (snapshot: SettingsScopeSnapshot<T>) => S,
|
||||
) {
|
||||
this.store = createSnapshotStore(project(scope.getSnapshot()))
|
||||
scope.subscribe(() => {
|
||||
this.store.set(this.project(this.scope.getSnapshot()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Plugin configuration fields: label, control, override badge, and hint. */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.reset {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
193
packages/client/ui-plugin-config/src/client/fields.tsx
Normal file
193
packages/client/ui-plugin-config/src/client/fields.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Hand-written controls for the plugin configuration forms. Each renders one
|
||||
* field's label, its current effective value, whether the user overrode it,
|
||||
* and — when overridden — the reset that clears it back to the composition
|
||||
* layer. Commits happen on blur and on Enter rather than per keystroke: a
|
||||
* write per keystroke would burn namespace revisions and race its own reads.
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import css from './fields.module.css'
|
||||
|
||||
/** What every field control needs regardless of its value type. */
|
||||
export interface FieldProps {
|
||||
/** Stable id associating the label with its control. */
|
||||
id: string
|
||||
/** Visible label. */
|
||||
label: string
|
||||
/** One-line explanation rendered under the control. */
|
||||
hint: string
|
||||
/** True when the raw user layer carries this field. */
|
||||
overridden: boolean
|
||||
/** Copy for the overridden badge. */
|
||||
overriddenLabel: string
|
||||
/** Copy for the reset control. */
|
||||
resetLabel: string
|
||||
/** Disables every control (read-only document, or an unavailable namespace). */
|
||||
disabled: boolean
|
||||
/** Clear the field so it re-inherits the composition layer. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/** Label, badge, and reset chrome shared by every control. */
|
||||
function FieldFrame(props: FieldProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
{props.overridden
|
||||
? (
|
||||
<span className={css.badges}>
|
||||
<span className={css.badge}>{props.overriddenLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.reset}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onReset}
|
||||
>
|
||||
{props.resetLabel}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{props.children}
|
||||
<p className={css.hint}>{props.hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a draft seeded from the authoritative value, re-seeding whenever that
|
||||
* value changes underneath (a Host acceptance, or a reset).
|
||||
* @param value - the current authoritative text.
|
||||
* @returns the draft and its setter.
|
||||
*/
|
||||
function useDraft(value: string): [string, (next: string) => void] {
|
||||
const [draft, setDraft] = useState(value)
|
||||
const [seed, setSeed] = useState(value)
|
||||
if (seed !== value) {
|
||||
setSeed(value)
|
||||
setDraft(value)
|
||||
}
|
||||
return [draft, setDraft]
|
||||
}
|
||||
|
||||
/** A whole-number field committed on blur or Enter. */
|
||||
export function NumberField(props: FieldProps & {
|
||||
/** Current effective value. */
|
||||
value: number
|
||||
/** Commit a parsed value; a draft that is not a finite number is discarded. */
|
||||
onCommit: (next: number) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(String(props.value))
|
||||
const commit = () => {
|
||||
const parsed = Number(draft)
|
||||
if (draft.trim() === '' || !Number.isFinite(parsed)) {
|
||||
setDraft(String(props.value))
|
||||
return
|
||||
}
|
||||
if (parsed === props.value) return
|
||||
props.onCommit(parsed)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}
|
||||
return (
|
||||
<FieldFrame {...props}>
|
||||
<input
|
||||
id={props.id}
|
||||
className={css.input}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { setDraft(event.target.value) }}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
|
||||
/** A free-text field committed on blur or Enter; an empty draft clears the field. */
|
||||
export function TextField(props: FieldProps & {
|
||||
/** Current effective value; the empty string when the field is unset. */
|
||||
value: string
|
||||
/** Placeholder shown while the draft is empty. */
|
||||
placeholder?: string
|
||||
/** Commit the trimmed draft. */
|
||||
onCommit: (next: string) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(props.value)
|
||||
const commit = () => {
|
||||
const next = draft.trim()
|
||||
if (next === props.value) return
|
||||
props.onCommit(next)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}
|
||||
return (
|
||||
<FieldFrame {...props}>
|
||||
<input
|
||||
id={props.id}
|
||||
className={css.input}
|
||||
type="text"
|
||||
value={draft}
|
||||
placeholder={props.placeholder ?? ''}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { setDraft(event.target.value) }}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A write-only credential field. The value never rides a response, so the
|
||||
* control reports only whether one is configured, and an empty draft commits
|
||||
* nothing — leaving the field blank keeps the stored key rather than clearing it.
|
||||
*/
|
||||
export function SecretField(props: Omit<FieldProps, 'overridden' | 'onReset'> & {
|
||||
/** Whether the Host reports a configured credential for this reference. */
|
||||
configured: boolean
|
||||
/** Copy describing the configured state. */
|
||||
stateLabel: string
|
||||
/** Commit a non-empty draft. */
|
||||
onCommit: (next: string) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const commit = () => {
|
||||
const next = draft.trim()
|
||||
if (next === '') return
|
||||
setDraft('')
|
||||
props.onCommit(next)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
<span className={css.badges}>
|
||||
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={css.input}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { setDraft(event.target.value) }}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<p className={css.hint}>{props.hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
packages/client/ui-plugin-config/src/client/index.ts
Normal file
91
packages/client/ui-plugin-config/src/client/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Plugin configuration surface, browser half — one settings section holding
|
||||
* an expandable card per Host plugin whose configuration a user owns.
|
||||
*
|
||||
* The section owns no knowledge of any namespace: it declares the
|
||||
* `settings.plugin.item` slot and renders whatever cards were registered into
|
||||
* it, so a plugin that ships a browser half contributes its own card and its
|
||||
* own controls. The three cards this package registers are the host-plane
|
||||
* sections the deployment already exposes; each binds its namespace through
|
||||
* the client settings scope, which keeps them unaware of one another.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { bindSettingsScope, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentLoopCard } from './AgentLoopCard.tsx'
|
||||
import { BashCard } from './BashCard.tsx'
|
||||
import { PluginConfigSection } from './PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
|
||||
import { BASH_NS, BashCardController } from './bash-store.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export { NumberField, SecretField, TextField, type FieldProps } from './fields.tsx'
|
||||
export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts'
|
||||
export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts'
|
||||
export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'settings.pluginConfig'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Mount the plugin configuration section and the cards this package ships.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-config: section dictionaries')
|
||||
|
||||
const bash = new BashCardController(bindSettingsScope(ctx, { namespace: BASH_NS }))
|
||||
const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api)
|
||||
|
||||
// The section renders the empty line rather than an empty list when no card
|
||||
// is registered; the ledger is read at render time so a card arriving later
|
||||
// (or leaving with its plugin) is reflected without the section subscribing.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugins',
|
||||
order: 30,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
|
||||
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
|
||||
}, PluginConfigSection))
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'bash',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: () => bash.inject(),
|
||||
}, BashCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'agent-loop',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => agentLoop.inject(),
|
||||
}, AgentLoopCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'web-search',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: () => webSearch.inject(),
|
||||
}, WebSearchCard)
|
||||
})
|
||||
}
|
||||
80
packages/client/ui-plugin-config/src/client/locales.ts
Normal file
80
packages/client/ui-plugin-config/src/client/locales.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/** Locale bundles for the plugin configuration section and its plugin cards. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type PluginConfigKey =
|
||||
| 'nav' | 'title' | 'intro' | 'empty'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
|
||||
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
|
||||
| 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint'
|
||||
| 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint'
|
||||
| 'webSearchTitle' | 'webSearchDescription'
|
||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginConfigKey, string> = {
|
||||
nav: 'Plugins',
|
||||
title: 'Plugin configuration',
|
||||
intro:
|
||||
'Settings owned by the plugins this deployment composes. A value you set here layers over the '
|
||||
+ 'composition default and applies to the next use.',
|
||||
empty: 'This deployment exposes no plugin settings.',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset to default',
|
||||
readOnly: 'This deployment stores settings read-only.',
|
||||
expand: 'Show settings',
|
||||
collapse: 'Hide settings',
|
||||
bashTitle: 'Shell',
|
||||
bashDescription: 'Limits every command the agent runs.',
|
||||
bashTimeoutMs: 'Command timeout (ms)',
|
||||
bashTimeoutMsHint: 'How long one command may run before it is terminated.',
|
||||
bashMaxOutputBytes: 'Output cap per stream (bytes)',
|
||||
bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.',
|
||||
agentLoopTitle: 'Agent loop',
|
||||
agentLoopDescription: 'How the agent dispatches tool calls.',
|
||||
agentLoopMaxParallel: 'Parallel tool calls',
|
||||
agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.',
|
||||
webSearchTitle: 'Web search',
|
||||
webSearchDescription: 'The DeepSeek search provider.',
|
||||
webSearchApiKey: 'API key',
|
||||
webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||
webSearchApiKeySet: 'A key is configured.',
|
||||
webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.',
|
||||
webSearchBaseUrl: 'Endpoint',
|
||||
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
|
||||
webSearchMaxUses: 'Max searches per request',
|
||||
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<PluginConfigKey, string> = {
|
||||
nav: '插件',
|
||||
title: '插件配置',
|
||||
intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。',
|
||||
empty: '本部署没有开放任何插件设置。',
|
||||
overridden: '已覆盖',
|
||||
reset: '恢复默认',
|
||||
readOnly: '本部署的设置为只读。',
|
||||
expand: '展开设置',
|
||||
collapse: '收起设置',
|
||||
bashTitle: '终端',
|
||||
bashDescription: '限制 agent 运行的每一条命令。',
|
||||
bashTimeoutMs: '命令超时(毫秒)',
|
||||
bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。',
|
||||
bashMaxOutputBytes: '单流输出上限(字节)',
|
||||
bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。',
|
||||
agentLoopTitle: 'Agent 循环',
|
||||
agentLoopDescription: 'Agent 如何派发工具调用。',
|
||||
agentLoopMaxParallel: '并行工具调用数',
|
||||
agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。',
|
||||
webSearchTitle: '网页搜索',
|
||||
webSearchDescription: 'DeepSeek 搜索提供方。',
|
||||
webSearchApiKey: 'API Key',
|
||||
webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||
webSearchApiKeySet: '已配置密钥。',
|
||||
webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。',
|
||||
webSearchBaseUrl: '接口地址',
|
||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||
webSearchMaxUses: '单次请求最多搜索次数',
|
||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||
}
|
||||
24
packages/client/ui-plugin-config/src/client/slot-contract.ts
Normal file
24
packages/client/ui-plugin-config/src/client/slot-contract.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
|
||||
* configuration section. Options: `id` (card key), `order` (card position).
|
||||
* A card draws its own internals; the section only stacks them and reports
|
||||
* how many there are.
|
||||
*
|
||||
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
|
||||
* packages that cannot reference its declarer, every current registrant of
|
||||
* this slot ships in this package, and a plugin registering its own card
|
||||
* already depends on this package for the card chrome. The type therefore
|
||||
* lives with the section that declares it at runtime.
|
||||
*/
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
|
||||
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a plugin card (the section supplies nothing). */
|
||||
export interface SettingsPluginItemOwnerProps {
|
||||
/** Marker field: card owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
144
packages/client/ui-plugin-config/src/client/web-search-store.ts
Normal file
144
packages/client/ui-plugin-config/src/client/web-search-store.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The web-search card's state and writes over the `web-search-deepseek`
|
||||
* settings namespace.
|
||||
*
|
||||
* The key is the one field that does not live in the section: its literal
|
||||
* never rides a response, so the card learns only whether one is configured
|
||||
* and writes it through the credentials domain, addressed by the reference
|
||||
* the section names.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the DeepSeek search provider. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const WEB_SEARCH_NS = 'web-search-deepseek'
|
||||
|
||||
/** Credential reference the provider resolves when the section names none. */
|
||||
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** The search-provider fields this card edits. */
|
||||
export interface WebSearchSettings {
|
||||
/** Credential reference naming the environment key. */
|
||||
apiKeyEnv?: string
|
||||
/** Provider endpoint; blank inherits the provider default. */
|
||||
baseURL?: string
|
||||
/** Maximum searches served within one request. */
|
||||
maxUses?: number
|
||||
}
|
||||
|
||||
/** What the web-search card renders. */
|
||||
export interface WebSearchCardState extends CardShell {
|
||||
/** Provider endpoint. */
|
||||
baseURL: CardField<string>
|
||||
/** Searches allowed per request. */
|
||||
maxUses: CardField<number>
|
||||
/** Credential reference the key is written under. */
|
||||
apiKeyRef: string
|
||||
/** Whether the Host reports a credential configured for that reference. */
|
||||
apiKeyConfigured: boolean
|
||||
}
|
||||
|
||||
/** The registration-side face the web-search card's slot entry injects. */
|
||||
export interface WebSearchCardFace {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
/** Write the provider endpoint; the empty string clears it. */
|
||||
setBaseUrl: (next: string) => void
|
||||
/** Clear the endpoint so it re-inherits the composition layer. */
|
||||
resetBaseUrl: () => void
|
||||
/** Write the per-request search budget. */
|
||||
setMaxUses: (next: number) => void
|
||||
/** Clear the budget so it re-inherits the composition layer. */
|
||||
resetMaxUses: () => void
|
||||
/** Write the credential the section references. */
|
||||
setApiKey: (next: string) => void
|
||||
}
|
||||
|
||||
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
|
||||
export class WebSearchCardController extends CardController<WebSearchSettings, WebSearchCardState> {
|
||||
private readonly credential: { configured: boolean }
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
|
||||
* @param api - wire face used for the credential the section references.
|
||||
*/
|
||||
constructor(scope: SettingsScope<WebSearchSettings>, private readonly api: Pick<IApiClient, 'credentials'>) {
|
||||
// Held in its own object because the projection runs during `super()`,
|
||||
// before `this` exists, and must still see the latest credential state:
|
||||
// that state comes from its own domain, so a settings change must not
|
||||
// silently reset it to unknown.
|
||||
const credential = { configured: false }
|
||||
super(scope, snapshot => ({
|
||||
...shellOf(snapshot),
|
||||
baseURL: fieldOf(snapshot, 'baseURL', ''),
|
||||
maxUses: fieldOf(snapshot, 'maxUses', 0),
|
||||
apiKeyRef: refOf(snapshot),
|
||||
apiKeyConfigured: credential.configured,
|
||||
}))
|
||||
this.credential = credential
|
||||
scope.subscribe(() => { void this.readCredential() })
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
/** Ask the credentials domain whether the referenced key exists. */
|
||||
private async readCredential(): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
|
||||
try {
|
||||
response = await this.api.credentials.describe({ refs: [ref] })
|
||||
} catch (_credentialReadFailure) {
|
||||
// The card stays usable without this: the key control simply reports the
|
||||
// last state it knew, and a write still reaches the Host.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) return
|
||||
const next = response.result.value.credentials[ref]?.configured ?? false
|
||||
if (next === this.credential.configured) return
|
||||
this.credential.configured = next
|
||||
this.store.set({ ...this.store.getSnapshot(), apiKeyConfigured: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its write actions.
|
||||
*/
|
||||
inject(): WebSearchCardFace {
|
||||
return {
|
||||
hooks: { webSearchCard: this.store },
|
||||
setBaseUrl: (next: string) => { void this.scope.set('baseURL', next) },
|
||||
resetBaseUrl: () => { void this.scope.unset('baseURL') },
|
||||
setMaxUses: (next: number) => { void this.scope.set('maxUses', next) },
|
||||
resetMaxUses: () => { void this.scope.unset('maxUses') },
|
||||
setApiKey: (next: string) => { void this.writeKey(next) },
|
||||
}
|
||||
}
|
||||
|
||||
private async writeKey(value: string): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
try {
|
||||
await this.api.credentials.set({ ref, value })
|
||||
} catch (_credentialWriteFailure) {
|
||||
// Refusals surface through the re-read below: the Host is the only
|
||||
// authority on whether the key now exists.
|
||||
}
|
||||
await this.readCredential()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential reference the section names, or the provider's default.
|
||||
* @param snapshot - the current scope snapshot.
|
||||
* @returns the reference to address.
|
||||
*/
|
||||
function refOf(snapshot: SettingsScopeSnapshot<WebSearchSettings>): string {
|
||||
const section = snapshot.value
|
||||
const declared = section?.apiKeyEnv
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
|
||||
}
|
||||
4
packages/client/ui-plugin-config/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-plugin-config/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
11
packages/client/ui-plugin-config/src/index.ts
Normal file
11
packages/client/ui-plugin-config/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plugin configuration surface, node half. The empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* settings section through exports["./client"], discovered from the
|
||||
* package.json dshClient declaration. Every section this page edits is owned
|
||||
* by the Host plugin that registered it, so this package registers no
|
||||
* namespace of its own.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-plugin-config/src/invariant.ts
Normal file
31
packages/client/ui-plugin-config/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plugin-config`.
|
||||
* @module @deepseek-ai/dsh-client-ui-plugin-config/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugin-config'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-plugin-config-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
|
||||
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
|
||||
* contracts covered by the owning plugins and the api-proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
100
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
100
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/** What the browser half registers, and that it all leaves with the fiber. */
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: {
|
||||
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
||||
credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) },
|
||||
},
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-plugin-config apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('registers the section and declares the per-plugin card slot', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('插件')
|
||||
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
})
|
||||
|
||||
it('registers one card per host-plane section it ships, in a stable order', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
|
||||
.toEqual(['bash', 'agent-loop', 'web-search'])
|
||||
})
|
||||
|
||||
it('injects a live card count and one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
|
||||
for (const entry of slots.entries('settings.plugin.item')) {
|
||||
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
|
||||
// Each card injects exactly one snapshot store plus its own actions.
|
||||
expect(Object.keys(face.hooks)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers into a declaration that arrives after apply', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
declareRoot(slots)
|
||||
|
||||
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('collapses every contribution on teardown', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(slots.spec('settings.plugin.item')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
330
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
330
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Field-control behavior: when a draft becomes a write, what a bad draft does
|
||||
* instead, and how an overridden field offers its reset.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NumberField, SecretField, TextField } from '../src/client/fields.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const frame = {
|
||||
id: 'field',
|
||||
label: 'Command timeout',
|
||||
hint: 'How long one command may run.',
|
||||
overriddenLabel: 'Overridden',
|
||||
resetLabel: 'Reset to default',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
describe('NumberField', () => {
|
||||
it('commits a changed draft on blur', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(9_000)
|
||||
})
|
||||
|
||||
it('commits on Enter through the blur the key triggers', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '1234' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(1_234)
|
||||
})
|
||||
|
||||
it('restores the last good value instead of committing a draft that is not a number', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'soon' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(input).toHaveProperty('value', '60000')
|
||||
})
|
||||
|
||||
it('writes nothing when the draft settles on the value already shown', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '60000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the reset only while the field is overridden', () => {
|
||||
const onReset = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NumberField {...frame} overridden={false} onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
|
||||
|
||||
expect(screen.getByText('Overridden')).toBeTruthy()
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-seeds the draft when the authoritative value changes underneath', () => {
|
||||
const { rerender } = render(
|
||||
<NumberField {...frame} overridden onReset={vi.fn()} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses every interaction while disabled', () => {
|
||||
const onCommit = vi.fn()
|
||||
const onReset = vi.fn()
|
||||
render(
|
||||
<NumberField
|
||||
{...frame}
|
||||
disabled
|
||||
overridden
|
||||
onReset={onReset}
|
||||
value={9_000}
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
expect(input).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(onReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextField', () => {
|
||||
it('commits the trimmed draft', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value=""
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('https://search.test/v1')
|
||||
})
|
||||
|
||||
it('commits an emptied draft, which clears the field', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden
|
||||
onReset={vi.fn()}
|
||||
value="https://search.test/v1"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('renders its placeholder and commits on Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
placeholder="https://api.deepseek.com"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value=""
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://other.test' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('https://other.test')
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter and writes nothing unchanged', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value="https://search.test/v1"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretField', () => {
|
||||
it('commits a non-empty draft and clears the control after writing', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ds-secret ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('ds-secret')
|
||||
expect(input).toHaveProperty('value', '')
|
||||
})
|
||||
|
||||
it('keeps the stored key when the draft is left blank', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('A key is configured.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
fireEvent.keyDown(input, { key: 'Tab' })
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never renders the value it writes', () => {
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('commits on Enter and stays disabled when the document is read-only', () => {
|
||||
const onCommit = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
expect(onCommit).toHaveBeenCalledWith('ds-secret')
|
||||
|
||||
rerender(
|
||||
<SecretField
|
||||
{...frame}
|
||||
disabled
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
223
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
223
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* What the section and its cards show: the empty line when no plugin
|
||||
* contributed one, a card that renders nothing while its namespace is
|
||||
* unavailable, and the read-only notice a locked document produces.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
|
||||
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
|
||||
import { BashCard } from '../src/client/BashCard.tsx'
|
||||
import type { BashCardProps } from '../src/client/BashCard.tsx'
|
||||
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
|
||||
import type { BashCardState } from '../src/client/bash-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: keyof typeof en) => en[key]
|
||||
|
||||
function renderSection(cardCount: number, cards = 'cards') {
|
||||
const props = {
|
||||
t,
|
||||
cardCount,
|
||||
renderSlot: () => <li>{cards}</li>,
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
timeoutMs: { value: 60_000, overridden: false },
|
||||
maxOutputBytes: { value: 64_000, overridden: false },
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
setTimeoutMs: vi.fn(),
|
||||
resetTimeoutMs: vi.fn(),
|
||||
setMaxOutputBytes: vi.fn(),
|
||||
resetMaxOutputBytes: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useBashCard: bindSnapshotSelector(store),
|
||||
} as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
describe('PluginConfigSection', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderSection(0)
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('cards')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the card list once a plugin contributed one', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByText('cards')).toBeTruthy()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
it('renders nothing while its namespace is unavailable', () => {
|
||||
const { container } = render(<div />)
|
||||
renderBash({ available: false })
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
expect(screen.queryByText(en.bashTitle)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plugin and reveals its fields only once expanded', () => {
|
||||
renderBash()
|
||||
expect(screen.getByText(en.bashTitle)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits an edited field through its action', () => {
|
||||
const actions = renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
const input = screen.getByLabelText(en.bashTimeoutMs)
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000)
|
||||
})
|
||||
|
||||
it('offers the reset for an overridden field only', () => {
|
||||
const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
// One badge and one reset: the output cap is still inherited.
|
||||
expect(screen.getAllByText(en.overridden)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetTimeoutMs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('says the document is read-only and disables its controls', () => {
|
||||
renderBash({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
it('edits the only field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
maxParallelToolCalls: { value: 10, overridden: false },
|
||||
})
|
||||
const setMaxParallelToolCalls = vi.fn()
|
||||
const props = {
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
setMaxParallelToolCalls,
|
||||
resetMaxParallelToolCalls: vi.fn(),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
const input = screen.getByLabelText(en.agentLoopMaxParallel)
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCard', () => {
|
||||
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
baseURL: { value: '', overridden: false },
|
||||
maxUses: { value: 5, overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
apiKeyConfigured: false,
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
setBaseUrl: vi.fn(),
|
||||
resetBaseUrl: vi.fn(),
|
||||
setMaxUses: vi.fn(),
|
||||
resetMaxUses: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useWebSearchCard: bindSnapshotSelector(store),
|
||||
} as unknown as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
it('reports whether a key is configured without ever showing one', () => {
|
||||
renderWebSearch({ apiKeyConfigured: true })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('keeps the key control usable while the settings document is read-only', () => {
|
||||
const actions = renderWebSearch({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const key = screen.getByLabelText(en.webSearchApiKey)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
fireEvent.blur(key)
|
||||
|
||||
expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret')
|
||||
})
|
||||
|
||||
it('commits the endpoint and the search budget', () => {
|
||||
const actions = renderWebSearch()
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const endpoint = screen.getByLabelText(en.webSearchBaseUrl)
|
||||
fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } })
|
||||
fireEvent.blur(endpoint)
|
||||
const budget = screen.getByLabelText(en.webSearchMaxUses)
|
||||
fireEvent.change(budget, { target: { value: '3' } })
|
||||
fireEvent.blur(budget)
|
||||
|
||||
expect(actions.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1')
|
||||
expect(actions.setMaxUses).toHaveBeenCalledWith(3)
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
193
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Card controllers: how a scope snapshot becomes card state, and which wire
|
||||
* call each action reaches.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
|
||||
|
||||
function credentialsApi(configured: boolean) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
|
||||
}))
|
||||
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
|
||||
return { api: { credentials: { describe, set } } as never, describe, set }
|
||||
}
|
||||
|
||||
describe('BashCardController', () => {
|
||||
it('publishes the effective value and marks only user-layer fields overridden', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
revision: 3,
|
||||
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
|
||||
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
writable: true,
|
||||
timeoutMs: { value: 5_000, overridden: true },
|
||||
maxOutputBytes: { value: 64_000, overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an override equal to the composition default as an override', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000 },
|
||||
base: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 60_000 },
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true })
|
||||
})
|
||||
|
||||
it('routes each action to its field write', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } })
|
||||
const actions = controller.inject()
|
||||
|
||||
actions.setTimeoutMs(9_000)
|
||||
actions.resetTimeoutMs()
|
||||
actions.setMaxOutputBytes(1_024)
|
||||
actions.resetMaxOutputBytes()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']])
|
||||
})
|
||||
|
||||
it('stays unavailable while the namespace is not served', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'unavailable' })
|
||||
|
||||
expect(controller.store.getSnapshot().available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCardController', () => {
|
||||
it('publishes the cap and routes its two actions', async () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { maxParallelToolCalls: 2 },
|
||||
base: { maxParallelToolCalls: 10 },
|
||||
user: { maxParallelToolCalls: 2 },
|
||||
})
|
||||
expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true })
|
||||
|
||||
const actions = controller.inject()
|
||||
actions.setMaxParallelToolCalls(4)
|
||||
actions.resetMaxParallelToolCalls()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4)
|
||||
expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls')
|
||||
})
|
||||
|
||||
it('reports a read-only document so the card can disable its controls', () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
|
||||
|
||||
expect(controller.store.getSnapshot().writable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the section names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true)
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
baseURL: { value: 'https://search.test/v1', overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the key through the credentials domain, never the settings section', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {} })
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
|
||||
expect(host.set).not.toHaveBeenCalledWith('apiKey', expect.anything())
|
||||
})
|
||||
|
||||
it('addresses the reference the section declares rather than the default', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' } })
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
|
||||
})
|
||||
|
||||
it('keeps the card usable when the credential read fails', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const controller = new WebSearchCardController(
|
||||
host.scope,
|
||||
{ credentials: { describe, set: vi.fn() } } as never,
|
||||
)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { value: 'https://search.test/v1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the endpoint and budget actions to their field writes', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {} })
|
||||
const actions = controller.inject()
|
||||
|
||||
actions.setBaseUrl('https://other.test')
|
||||
actions.resetBaseUrl()
|
||||
actions.setMaxUses(3)
|
||||
actions.resetMaxUses()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-plugin-config/tsconfig.json
Normal file
42
packages/client/ui-plugin-config/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../test-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-plugin-config/tsdown.config.ts
Normal file
3
packages/client/ui-plugin-config/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-config', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -1508,6 +1508,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-plan':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-plan
|
||||
'@deepseek-ai/dsh-client-ui-plugin-config':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-plugin-config
|
||||
'@deepseek-ai/dsh-client-ui-question':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-question
|
||||
@@ -2287,6 +2290,45 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-plugin-config:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../test-runtime
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-web-react':
|
||||
specifier: workspace:^
|
||||
version: link:../web-react
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-primitives:
|
||||
dependencies:
|
||||
'@shikijs/langs':
|
||||
|
||||
@@ -77,6 +77,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
|
||||
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
|
||||
'packages/client/ui-plugin-config': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
|
||||
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
|
||||
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"],
|
||||
"@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"],
|
||||
"@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"],
|
||||
"@deepseek-ai/dsh-client-ui-plugin-config": ["./packages/client/ui-plugin-config/src"],
|
||||
"@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"],
|
||||
"@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
{ "path": "./packages/client/ui-agent-preset" },
|
||||
{ "path": "./packages/client/ui-permission" },
|
||||
{ "path": "./packages/client/ui-plan" },
|
||||
{ "path": "./packages/client/ui-plugin-config" },
|
||||
{ "path": "./packages/client/ui-question" },
|
||||
{ "path": "./packages/client/ui-trajectory" },
|
||||
{ "path": "./packages/client/ui-theme" },
|
||||
|
||||
Reference in New Issue
Block a user