Merge commit '2f717c0467d7174d7fe0011c4a787450f7328a78' into codex/product-subagent-one-shot-background
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 7904ec1fea8dbab361fcd90b2b32f1c743ce5537
|
||||
README.zh.md: bac14113b656990cfda18a448034bfa25482c4fc
|
||||
README.zh.md: c39b65dad7e9c277779a85d7327982597628e007
|
||||
|
||||
@@ -10,53 +10,53 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定接口 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 Typert RPC Gateway | 产品:稳定接口 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
|
||||
| [`schedule/`](schedule/README.md) | 仅限会话内的定时后续轮次 | 产品:稳定接口 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
|
||||
| [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定接口 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定接口 |
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定 API |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 Typert RPC 网关 | 产品:稳定 API |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定 API |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定 API |
|
||||
| [`schedule/`](schedule/README.md) | 仅限会话内的定时后续操作 | 产品:稳定 API |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定 API |
|
||||
| [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定 API |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API |
|
||||
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定接口 |
|
||||
| [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定接口 |
|
||||
| [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定接口 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定接口 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定接口 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、由 bash 支持的发现工具 | 产品:稳定接口 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定接口 |
|
||||
| [`skill/`](skill/README.md) | skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/loader | 产品:稳定接口 |
|
||||
| [`compaction/`](compaction/README.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定接口 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定接口 |
|
||||
| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定接口 |
|
||||
| [`jobs/`](jobs/README.md) | 通用后台任务运行时和面向模型的 `job_*` 控制工具 | 产品:稳定接口 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
|
||||
| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 |
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
|
||||
| [`extensions/`](extensions/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定接口 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定接口 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定接口 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定 API |
|
||||
| [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API |
|
||||
| [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定 API |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、由 bash 支持的发现工具 | 产品:稳定 API |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定 API |
|
||||
| [`skill/`](skill/README.md) | skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/loader | 产品:稳定 API |
|
||||
| [`compaction/`](compaction/README.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定 API |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定 API |
|
||||
| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定 API |
|
||||
| [`jobs/`](jobs/README.md) | 通用后台任务运行时和面向模型的 `job_*` 控制工具 | 产品:稳定 API |
|
||||
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定 API |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定 API |
|
||||
| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定 API |
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定 API |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定 API |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定 API |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定 API |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定 API |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定 API |
|
||||
| [`extensions/`](extensions/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定 API |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享的 Claude Code/Codex 线协议库 | 产品:稳定 API |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定 API |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API |
|
||||
| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | 产品:稳定 API |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | 产品:稳定 API |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定 API |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定 API |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定 API |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI(命令行界面)/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`test-support/`](test-support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、留存) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
新包加入现有组;新组更新其 README 和此表。
|
||||
|
||||
@@ -64,6 +64,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
|
||||
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。
|
||||
|
||||
**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力在 Service Definition/Service Provider/Consumer 角色需要独立演进时将其分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力会将需要独立演进的 Service Definition/Service Provider/Consumer 角色分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
|
||||
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或列入其[允许清单](../scripts/verify-package-readme-limitations.ts)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-base",
|
||||
"description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-headless",
|
||||
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: 3f7fbe7bbbeba2ee753ab2dfa8c945e798dd6a02
|
||||
README.zh.md: 357ca9946ed7fef1bf4c691c6666156bb293b5e0
|
||||
README.md: 90d1566b5a7a25f6a827079c4a8ab776e05cc7a3
|
||||
README.zh.md: b7156bcab66b53964bb2f0e1c87d9e9a806e3f75
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-app",
|
||||
"description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -45,28 +45,30 @@ function webCommand(): Command {
|
||||
.name('dsh --profile web')
|
||||
.description('Serve the DeepSeek Harness browser UI.')
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--host <host>', 'bind host')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web serve on the composed host and port
|
||||
dsh --profile web --port 8080 serve on another port
|
||||
dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the Web invocation as an ordinary Cordis service. The
|
||||
* command's action publishes the flags this invocation named; a non-numeric
|
||||
* `--port` is a usage error, so on rejection (and on `--help`) nothing is
|
||||
* provided.
|
||||
* command's action publishes the flags this invocation named; `--host 0.0.0.0`
|
||||
* or a non-numeric `--port` is a usage error, so on rejection (and on `--help`)
|
||||
* nothing is provided.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const program = webCommand()
|
||||
program.action(() => {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.host === '0.0.0.0') {
|
||||
program.error('error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
|
||||
}
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
describe('web command-line provider', () => {
|
||||
it('publishes each flag and releases direct service expressions', async () => {
|
||||
const { values, observed } = await bootProvider([
|
||||
'--host', '0.0.0.0',
|
||||
'--host', '127.0.0.1',
|
||||
'--port', '8080',
|
||||
'--trusted-host', 'lab.internal', 'lab-2.internal',
|
||||
'--trusted-host', '10.0.0.9',
|
||||
])
|
||||
expect(values).toEqual({
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
|
||||
})
|
||||
@@ -128,4 +128,12 @@ describe('web command-line provider', () => {
|
||||
expect(observed.readerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('rejects the intentionally unsupported all-interfaces host before the consumer activates', async () => {
|
||||
const { values, observed } = await bootProvider(['--host', '0.0.0.0'])
|
||||
expect(observed.out).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
|
||||
expect(values).toBeUndefined()
|
||||
expect(observed.readerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/connection/README.md
|
||||
README.md: 3315552dd0400697d5a96639598c5f5bb2bb4f5b
|
||||
README.zh.md: 0dc251f1e5410bd0d74ef450c6d8df3dc9cdab81
|
||||
README.md: f0a707cc5f3a962c7852f7323c727d0a39a57b10
|
||||
README.zh.md: b529eebf9af93e36b6b92c19964678b7f5a04ea1
|
||||
|
||||
@@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, deployment-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. Non-loopback compositions must trust their serving authorities explicitly: the Web runtime derives LAN IP literals from an all-interfaces server config, while `trustedHosts` in cordis.yml and the CLI's `--trusted-host` flag declare named authorities. `dsh web --host 0.0.0.0` is intentionally unsupported until remote access has an authentication layer. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## `/api` WebSocket downlinks
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI(命令行界面)推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* nor Fetch-Metadata to reads (images and navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback,
|
||||
* deployment-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* Network reachability and authentication stay out of scope: binding policy
|
||||
* belongs to the webserver config, and this fence is not an auth layer.
|
||||
*/
|
||||
|
||||
@@ -199,8 +199,8 @@ describe('connection node half', () => {
|
||||
const loopback = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
|
||||
expect(loopback.state.status).toBe(404)
|
||||
// LAN authority declared as a port-less IP literal — the shape the CLI
|
||||
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
|
||||
// An all-interfaces composition derives port-less LAN IP literals, which
|
||||
// pass markerless curl on any port.
|
||||
const lan = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
|
||||
expect(lan.state.status).toBe(404)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-schema-form",
|
||||
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
|
||||
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-attachment",
|
||||
"description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-commands",
|
||||
"description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-conversation",
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-deliverables",
|
||||
"description": "Produced-files turn tail and clickable final-response file references for Web",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
|
||||
"description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
|
||||
"description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-goal",
|
||||
"description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-input-trigger",
|
||||
"description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-jobs",
|
||||
"description": "Session-header background-job list: live registry state mirrored from session/jobs frames",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-layout",
|
||||
"description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-message-feedback",
|
||||
"description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-model-selection",
|
||||
"description": "Model selection: the /model popupSelect over session.models / session.selectModel",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-permission-presets",
|
||||
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plan",
|
||||
"description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
||||
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/ui-settings-general/README.md
|
||||
README.md: 6669442f322ee38f14fff45e4344b59dffb3d61a
|
||||
README.zh.md: 53d006dd521fbc69e83c91914418bf98f0342bf9
|
||||
README.md: c59b77617cfd5848553ba340e97bafe37b7b2a2f
|
||||
README.zh.md: ea4896284b677a7b430cceb949b53aa6e3a5a242
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
Settings shell, ownerless copy, and durable product-onboarding namespace. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted step at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
The shell ships no copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||
The shell ships no onboarding copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one step at a time. Visible steps own their dialog chrome and app-root `inert` lifecycle; a mounted step still resolving private facts renders null, so nothing paints or blocks while it decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||
|
||||
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
||||
|
||||
The Host half registers `ui-onboarding` in the user-settings seam. Its `welcomeNoticeVersion` field keeps acknowledgements already present in stored settings documents valid and has no reader; no shipped step writes it.
|
||||
The Host half registers `ui-onboarding` in the user-settings seam. The welcome step contributed by `ui-settings-models` reads and writes its `welcomeNoticeVersion` through the existing public settings boundary; the shell itself remains policy-free.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
设置外壳、无特定功能归属文案与持久化产品引导 namespace。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一个步骤的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
外壳不自带引导文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个步骤;可见步骤自行持有弹窗框架和应用根节点 `inert` 生命周期。已挂载但仍在判定私有事实的步骤渲染 null,因此判定期间不绘制也不阻塞任何内容。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及可见包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
|
||||
|
||||
宿主端在用户设置 seam 中注册 `ui-onboarding`。其中的 `welcomeNoticeVersion` 字段让既有设置文档中已写入的确认记录保持有效;它没有读取方,也没有任何已发布的引导步骤写入它。
|
||||
宿主端在用户设置 seam 中注册 `ui-onboarding`。`ui-settings-models` 提供的欢迎步骤通过既有公开 settings 边界读写其中的 `welcomeNoticeVersion`;外壳本身仍不持有产品策略。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
||||
* open state and the active section id are component-local viewing state;
|
||||
* the onboarding coordinator mounts exactly one ordered registrant while the
|
||||
* sessions-derived empty-Hero fact is active — the takeover chrome
|
||||
* (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
|
||||
* paints nothing here.
|
||||
* sessions-derived empty-Hero fact is active. Visible dialog chrome belongs
|
||||
* to the step, so a mounted-but-deciding step paints nothing here.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -160,10 +159,9 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
|
||||
inert) lives inside the step component, wrapped around its visible
|
||||
content — a step still deciding (private facts loading) renders
|
||||
null, so nothing paints or blocks while it decides. */}
|
||||
{/* Dialog chrome and `#root` inert ownership live inside each step's
|
||||
visible branch. A step still deciding (private facts loading)
|
||||
renders null, so nothing paints or blocks while it decides. */}
|
||||
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
|
||||
stepId: onboardingStep.id,
|
||||
complete: () => { completeOnboardingStep(onboardingStep.id) },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
const ONBOARDING_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
interface OnboardingSettings {
|
||||
/** Last welcome-notice version acknowledged by a since-removed first-run notice; kept so stored documents stay valid. */
|
||||
/** Last version acknowledged by the current product welcome step. */
|
||||
welcomeNoticeVersion?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('SettingsPanel navigation', () => {
|
||||
|
||||
it('paints no takeover chrome of its own around the mounted step', () => {
|
||||
// The chrome (mask, opaque stage, #root inert) belongs to the step via
|
||||
// the OnboardingSurface primitive — a mounted-but-deciding step that
|
||||
// the step-owned dialog surface — a mounted-but-deciding step that
|
||||
// renders null must show and block nothing (the reload white-flash fix;
|
||||
// onboarding-surface.spec.tsx pins the primitive's half).
|
||||
const appRoot = document.createElement('div')
|
||||
|
||||
@@ -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/ui-settings-models/README.md
|
||||
README.md: 642dc3ffc7f3f45a523cd6e84b13d34e2d25dc2a
|
||||
README.zh.md: 408f2017636ef2d9a5a57fef253f2f05b61a22fb
|
||||
README.md: f0094c4f2687beaf6a5e5f7a1a8dc3d4ebdbcbc0
|
||||
README.zh.md: 68bb64ddeebfd65644edbb5e11fd2e147214d44a
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
Models settings and product-onboarding plugin. The same client Cordis plugin registers the Models page plus two ordered first-run dialogs: a versioned internal-testing notice and the conditional official-DeepSeek credential step. Both steps share one modal wrapper and remain sequenced by `settings.onboarding`. The Models plane joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
The notice step owns its exact copy and version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload.
|
||||
|
||||
After that notice completes, the DeepSeek step projects first-run readiness from the same joined Models snapshot. ANY provider the user can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference and therefore authenticates natively. Only a user with none is asked for the official DeepSeek key. A mounted, active adapter with a missing writable reference renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. Configure later completes only this coordinator pass. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret slot)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
模型设置与产品引导插件。同一个 client Cordis 插件会注册 Models 页面和两个有序的首次使用弹窗:版本化内测声明,以及按条件显示的 DeepSeek 官方凭据步骤。两个步骤共用同一套弹窗组件,并继续由 `settings.onboarding` 排序。Models 平面把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret slot)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
声明步骤在 `src/onboarding-copy.ts` 中持有完整文案和版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。
|
||||
|
||||
声明完成后,DeepSeek 步骤会从同一个 Models 联接快照得出首次运行就绪状态。只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名引用、因而走原生认证的路由。只有二者皆无的用户才会被要求填写 DeepSeek 官方密钥。适配器已挂载且活跃、引用可写但尚未配置时,既有 `ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中;`credentials.set` 仍是唯一的 secret 写入,且不会改变提供方设置。「稍后配置」只完成协调器当前这一轮。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤不渲染并直接完成;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-models",
|
||||
"description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join",
|
||||
"version": "0.0.1-rc.5",
|
||||
"description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,99 +1,16 @@
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(104px, 18vh, 156px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.description { animation-delay: 80ms; }
|
||||
.actions { animation-delay: 120ms; }
|
||||
|
||||
@keyframes credential-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: none;
|
||||
}
|
||||
.editor {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.later {
|
||||
width: 100%;
|
||||
.editor {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,39 @@
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page: any provider the user
|
||||
* can already talk to ends the step, and only a user with none is offered the
|
||||
* official DeepSeek route. The prompt itself only routes to that page's single
|
||||
* credential editor.
|
||||
* official DeepSeek route. The step reuses that page's credential editor in
|
||||
* the onboarding plugin's shared modal, so the key is entered once.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { onboardingReadiness } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import { OnboardingModal } from './OnboardingModal.tsx'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
|
||||
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
|
||||
/** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */
|
||||
export interface DeepSeekOnboardingInjected {
|
||||
hooks: {
|
||||
/** Shared Models-page join state, bound by the slot renderer. */
|
||||
models: SnapshotStore<ModelsSettingsState>
|
||||
}
|
||||
/** Shared Models-page join controller. */
|
||||
controller: ModelsSettingsStore
|
||||
/** Subscription hook bound to the shared join snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Existing wire face reused by the Models credential editor. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Feature copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Slot owner props plus the feature's injected dependencies. */
|
||||
export type DeepSeekOnboardingDialogProps =
|
||||
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
|
||||
PropsRuntime<'settings.onboarding'> & InjectFace<DeepSeekOnboardingInjected>
|
||||
|
||||
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
|
||||
function assertNever(_value: never): never {
|
||||
@@ -36,16 +42,15 @@ function assertNever(_value: never): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt a first-run user to open Models while no provider can serve requests
|
||||
* and the official adapter exists with an unconfigured effective credential.
|
||||
* Prompt a first-run user for the official DeepSeek credential while no
|
||||
* provider can serve requests and that credential is writable.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
* @returns the onboarding modal or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const { complete, controller, useModels, api, t } = props
|
||||
const state = useModels(snapshot => snapshot)
|
||||
const readiness = onboardingReadiness(state)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
@@ -59,18 +64,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
|
||||
}, [readiness.kind])
|
||||
|
||||
const openModels = (): void => {
|
||||
complete()
|
||||
openSection('models')
|
||||
}
|
||||
|
||||
// Null covers the still-deciding and nothing-to-do states alike: the
|
||||
// takeover chrome below is part of THIS render, so declining paints and
|
||||
// blocks nothing while the shared join is in flight.
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
@@ -84,28 +77,44 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
return assertNever(readiness)
|
||||
}
|
||||
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
&& candidate.entry.settingsPath.length === 0)
|
||||
const namespace = state.namespaces.get('llm-deepseek')
|
||||
/* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
|
||||
if (row === undefined || namespace === undefined) return null
|
||||
|
||||
const finishCredential = (changed: boolean): void => {
|
||||
if (!changed) {
|
||||
complete()
|
||||
return
|
||||
}
|
||||
void controller.load()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingSurface>
|
||||
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
|
||||
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2
|
||||
ref={titleRef}
|
||||
id="deepseek-onboarding-title"
|
||||
className={styles['title']}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('onboardingTitle')}
|
||||
</h2>
|
||||
<p className={styles['description']}>{t('onboardingDescription')}</p>
|
||||
<div className={styles['actions']}>
|
||||
<Button variant="ghost" className={styles['later']} onClick={complete}>
|
||||
{t('onboardingLater')}
|
||||
</Button>
|
||||
<Button variant="primary" className={styles['primary']} onClick={openModels}>
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</OnboardingSurface>
|
||||
<OnboardingModal title={t('onboardingTitle')}>
|
||||
<p className={styles.description}>{t('onboardingDescription')}</p>
|
||||
<div className={styles.editor}>
|
||||
<ProviderEditor
|
||||
provider={row.entry.provider}
|
||||
displayName={row.entry.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={row.entry.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
hideTitle
|
||||
credentialOnly
|
||||
credentialRequired
|
||||
autoFocusCredential
|
||||
cancelLabel="onboardingLater"
|
||||
submitLabel="onboardingSave"
|
||||
submitBusyLabel="onboardingSaving"
|
||||
onClose={finishCredential}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface EditorFooterProps {
|
||||
submitLabel: keyof typeof en
|
||||
/** Commit label while a commit is in flight. */
|
||||
submitBusyLabel: keyof typeof en
|
||||
/** Dismiss label; defaults to the settings editor copy. */
|
||||
cancelLabel?: keyof typeof en
|
||||
/** Dismiss the card without committing. */
|
||||
onCancel: () => void
|
||||
/** Run the card's commit. */
|
||||
@@ -50,7 +52,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode {
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
{t('cancel')}
|
||||
{t(props.cancelLabel ?? 'cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.dialog {
|
||||
width: min(600px, 100%);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Shared modal chrome for every step registered by this onboarding plugin. */
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './OnboardingModal.module.css'
|
||||
|
||||
const ignoreImplicitDismiss = (): void => {}
|
||||
|
||||
/**
|
||||
* Render a blocking onboarding dialog and keep the application root inert.
|
||||
* @param props.title - accessible and visible dialog title.
|
||||
* @param props.focusTitle - focus the title when the step has no form control.
|
||||
* @param props.children - step-owned body and actions.
|
||||
* @returns the body-portaled modal.
|
||||
*/
|
||||
export function OnboardingModal({
|
||||
title, focusTitle = false, children,
|
||||
}: {
|
||||
title: string
|
||||
focusTitle?: boolean
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const appRoot = document.getElementById('root')
|
||||
if (appRoot === null) return
|
||||
const previous = appRoot.inert
|
||||
appRoot.inert = true
|
||||
return () => { appRoot.inert = previous }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (focusTitle) titleRef.current?.focus()
|
||||
}, [focusTitle])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={title}
|
||||
onClose={ignoreImplicitDismiss}
|
||||
headless
|
||||
className={css.dialog as string}
|
||||
>
|
||||
<div className={css.content}>
|
||||
<h2 ref={titleRef} className={css.title} tabIndex={focusTitle ? -1 : undefined}>{title}</h2>
|
||||
<div className={css.body}>{children}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,18 @@ export interface ProviderEditorProps {
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Render only the credential field and actions, without provider settings. */
|
||||
credentialOnly?: boolean
|
||||
/** Require a newly entered credential before this editor can submit. */
|
||||
credentialRequired?: boolean
|
||||
/** Give the credential field initial focus when this editor mounts. */
|
||||
autoFocusCredential?: boolean
|
||||
/** Override the dismiss action copy. */
|
||||
cancelLabel?: keyof typeof en
|
||||
/** Override the idle commit action copy. */
|
||||
submitLabel?: keyof typeof en
|
||||
/** Override the in-flight commit action copy. */
|
||||
submitBusyLabel?: keyof typeof en
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
onClose: (changed: boolean) => void
|
||||
}
|
||||
@@ -198,6 +210,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
// as "no key supplied" rather than as a key — that is how a card whose
|
||||
// provider already has a stored key is edited without re-entering it.
|
||||
const keyValue = keyDraft.trim()
|
||||
const credentialRequiredFailure = props.credentialRequired === true
|
||||
&& keyDraft.length > 0 && keyValue.length === 0
|
||||
? 'keyRequired' as const
|
||||
: undefined
|
||||
const shownKeyFailure = credentialRequiredFailure ?? keyFailure
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
@@ -225,7 +242,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
if (props.credentialOnly !== true) {
|
||||
// The same checker gates the submit button, so a card cannot reach this
|
||||
// with a bad row; it stays because the schema check below would refuse
|
||||
// the write with a message naming a path instead of the row, and because
|
||||
@@ -237,7 +254,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined && settingsPath.length === 0) {
|
||||
if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
@@ -245,9 +262,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
const ops: SettingsPathOpView[] = props.credentialOnly === true
|
||||
? []
|
||||
: materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
@@ -324,7 +343,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true
|
||||
: keyState?.configured === true && props.credentialRequired !== true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
@@ -349,12 +368,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
value={keyDraft}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
aria-invalid={shownKeyFailure !== undefined}
|
||||
required={props.credentialRequired === true}
|
||||
autoFocus={props.autoFocusCredential === true}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
|
||||
{shownKeyFailure === undefined ? null : <p className={styles['error']}>{t(shownKeyFailure)}</p>}
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
{props.credentialOnly === true ? null : <details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{/* The name and the protocol are the create card's two remaining
|
||||
@@ -440,13 +462,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
)
|
||||
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</details>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={props.credentialOnly === true ? styles['addBlock'] : styles['editor']}>
|
||||
{props.hideTitle === true
|
||||
? null
|
||||
: (
|
||||
@@ -461,7 +483,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
|
||||
: curatedFields(layout)}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{modelFailure === undefined
|
||||
{props.credentialOnly === true || modelFailure === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['advancedHint']}>
|
||||
@@ -471,10 +493,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
|
||||
|| keyFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
submitDisabled={disabled || layout === 'unknown'
|
||||
|| (props.credentialOnly !== true && modelFailure !== undefined)
|
||||
|| shownKeyFailure !== undefined
|
||||
|| (props.credentialRequired === true && keyValue.length === 0)}
|
||||
submitLabel={props.submitLabel ?? 'apply'}
|
||||
submitBusyLabel={props.submitBusyLabel ?? 'applying'}
|
||||
{...props.cancelLabel === undefined ? {} : { cancelLabel: props.cancelLabel }}
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onSubmit={() => { void apply() }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.copy {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy p + p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Product-wide, versioned internal-testing notice. */
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import { OnboardingModal } from './OnboardingModal.tsx'
|
||||
import css from './WelcomeNotice.module.css'
|
||||
|
||||
/** Registration-side dependencies of {@link WelcomeNotice}. */
|
||||
export interface WelcomeNoticeInjected {
|
||||
hooks: {
|
||||
/** Durable or process-local acknowledgement state. */
|
||||
welcome: SnapshotStore<WelcomeNoticeState>
|
||||
}
|
||||
/** Welcome acknowledgement controller. */
|
||||
controller: WelcomeNoticeStore
|
||||
/** Onboarding copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Coordinator owner props plus this step's injected face. */
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & InjectFace<WelcomeNoticeInjected>
|
||||
|
||||
/**
|
||||
* Render the current notice until its exact copy version is acknowledged.
|
||||
* @param props - settings-shell owner state and welcome dependencies.
|
||||
* @returns the welcome modal or null while the step decides not to show.
|
||||
*/
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useWelcome, t } = props
|
||||
const state = useWelcome(snapshot => snapshot)
|
||||
const finished = useRef(false)
|
||||
const finish = useCallback((): void => {
|
||||
if (finished.current) return
|
||||
finished.current = true
|
||||
complete()
|
||||
}, [complete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.acknowledged) finish()
|
||||
}, [finish, state.acknowledged])
|
||||
|
||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
||||
|
||||
const acknowledge = async (): Promise<void> => {
|
||||
if (await controller.acknowledge()) finish()
|
||||
}
|
||||
const paragraphs = t('welcomeBody').split('\n\n')
|
||||
|
||||
return (
|
||||
<OnboardingModal title={t('welcomeTitle')} focusTitle>
|
||||
<div className={css.copy}>
|
||||
{paragraphs.map(paragraph => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcomeError')}</p>}
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.primary}
|
||||
disabled={state.status === 'saving'}
|
||||
onClick={() => { void acknowledge() }}
|
||||
>
|
||||
{t('welcomeContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingModal>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Models settings plugin, browser half. Registers the `models` nav entry and
|
||||
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
|
||||
* one provider/settings/credential join; the overlay routes missing-key users
|
||||
* to the full page's single credential editor. Export discipline:
|
||||
* Models settings and product-onboarding plugin, browser half. It registers
|
||||
* the Models page plus the ordered internal-testing and official-DeepSeek
|
||||
* onboarding dialogs, whose UI shares this package's modal wrapper. The Host
|
||||
* settings and credential contracts stay behind their existing wire APIs.
|
||||
* Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -19,15 +20,19 @@ import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh, type ModelsKey } from './locales.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The Models page + onboarding overlay copy. */
|
||||
/** The Models page + product-onboarding copy. */
|
||||
'settings.models': ModelsKey
|
||||
}
|
||||
}
|
||||
@@ -74,21 +79,38 @@ export function apply(ctx: ClientContext): void {
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
|
||||
const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
|
||||
controller,
|
||||
useSnapshot,
|
||||
hooks: { models: controller.store },
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const welcomeController = new WelcomeNoticeStore(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
hooks: { welcome: welcomeController.store },
|
||||
t,
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshModels = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshAll = (): void => {
|
||||
refreshModels()
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.remote.$on('credentials/updated', refresh),
|
||||
ctx.remote.$on('llm/adapters-updated', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
refreshModels()
|
||||
if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController)
|
||||
}),
|
||||
ctx.remote.$on('credentials/updated', refreshModels),
|
||||
ctx.remote.$on('llm/adapters-updated', refreshModels),
|
||||
ctx.on('connection/reset', refreshAll),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-models: pushed invalidations')
|
||||
@@ -100,10 +122,16 @@ export function apply(ctx: ClientContext): void {
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'deepseek-official',
|
||||
order: 0,
|
||||
inject: onboardingInjected,
|
||||
inject: deepSeekOnboardingInjected,
|
||||
}, DeepSeekOnboardingDialog))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Copy dictionaries for the Models settings section. */
|
||||
|
||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
||||
|
||||
/** English strings (the key-set source of truth for this pair). */
|
||||
export const en = {
|
||||
nav: 'Models',
|
||||
@@ -85,17 +87,23 @@ export const en = {
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
creating: 'Creating\u2026',
|
||||
welcomeTitle: WELCOME_NOTICE_COPY.en.title,
|
||||
welcomeBody: WELCOME_NOTICE_COPY.en.body,
|
||||
welcomeContinue: WELCOME_NOTICE_COPY.en.continueLabel,
|
||||
welcomeError: 'The acknowledgement could not be saved. Please try again.',
|
||||
onboardingTitle: 'Add an API key to get started',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingGoToSettings: 'Go to settings',
|
||||
onboardingLater: 'Configure later',
|
||||
onboardingSave: 'Save and continue',
|
||||
onboardingSaving: 'Saving…',
|
||||
keyRequired: 'Enter an API key to continue.',
|
||||
}
|
||||
|
||||
/** The settings.models namespace key union. */
|
||||
export type ModelsKey = keyof typeof en
|
||||
|
||||
/** Chinese strings (same keys as {@link en}). */
|
||||
export const zh: typeof en = {
|
||||
export const zh: { [Key in keyof typeof en]: string } = {
|
||||
nav: '模型',
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
@@ -179,8 +187,14 @@ export const zh: typeof en = {
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
creating: '创建中\u2026',
|
||||
welcomeTitle: WELCOME_NOTICE_COPY.zh.title,
|
||||
welcomeBody: WELCOME_NOTICE_COPY.zh.body,
|
||||
welcomeContinue: WELCOME_NOTICE_COPY.zh.continueLabel,
|
||||
welcomeError: '暂时无法保存确认状态,请重试。',
|
||||
onboardingTitle: '添加一个 API Key 开始使用',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingGoToSettings: '前往配置',
|
||||
onboardingLater: '稍后配置',
|
||||
onboardingSave: '保存并继续',
|
||||
onboardingSaving: '保存中…',
|
||||
keyRequired: '请输入 API 密钥后继续。',
|
||||
}
|
||||
|
||||
126
packages/client/ui-settings-models/src/client/welcome-store.ts
Normal file
126
packages/client/ui-settings-models/src/client/welcome-store.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
export interface WelcomeNoticeState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
acknowledged: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - settings wire face used for durable reads and writes.
|
||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
||||
return
|
||||
}
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version, or advance only this process for a remote browser.
|
||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
return true
|
||||
}
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
||||
* acknowledgement so reconnect does not reopen a process-local notice.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
25
packages/client/ui-settings-models/src/onboarding-copy.ts
Normal file
25
packages/client/ui-settings-models/src/onboarding-copy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
/** Field storing the last welcome notice version the user acknowledged. */
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
|
||||
/**
|
||||
* Bump only when the notice changes materially and every user should see it
|
||||
* again. The acknowledgement is compared for exact equality.
|
||||
*/
|
||||
export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
|
||||
|
||||
/** The complete editable internal-testing notice in both supported GUI locales. */
|
||||
export const WELCOME_NOTICE_COPY = {
|
||||
zh: {
|
||||
title: '内测声明',
|
||||
body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
en: {
|
||||
title: 'Internal Testing Notice',
|
||||
body: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.",
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
} as const
|
||||
@@ -8,12 +8,13 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
|
||||
// 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() {
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
@@ -23,7 +24,7 @@ async function bench() {
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {} } as never)
|
||||
ctx.provide('connection', { api: {}, isLoopback } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
|
||||
}
|
||||
|
||||
@@ -60,9 +61,20 @@ describe('ui-settings-models apply', () => {
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
const onboarding = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
|
||||
const onboarding = before.slots.entries('settings.onboarding')
|
||||
expect(onboarding).toHaveLength(2)
|
||||
expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({
|
||||
component: WelcomeNotice,
|
||||
options: { id: 'welcome-notice', order: -100 },
|
||||
})
|
||||
const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
|
||||
expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
|
||||
const deepSeekInjected = (
|
||||
deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
|
||||
)()
|
||||
expect(deepSeekInjected.hooks.models).toBe(injected.controller.store)
|
||||
expect(deepSeekInjected.api).toBeDefined()
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -71,7 +83,7 @@ describe('ui-settings-models apply', () => {
|
||||
declare(after.slots)
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(after.slots.entries('settings.onboarding')).toHaveLength(2)
|
||||
// The self-inflicted ledger notifications hit the duplicate guard.
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(1)
|
||||
})
|
||||
@@ -110,7 +122,7 @@ describe('ui-settings-models apply', () => {
|
||||
declare(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(b.slots.entries('settings.onboarding')).toHaveLength(2)
|
||||
// The locale path also recovers through the same ledger re-check.
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
@@ -130,6 +142,22 @@ describe('ui-settings-models apply', () => {
|
||||
expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
|
||||
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps remote-browser acknowledgement in process memory', async () => {
|
||||
const b = await bench(false)
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')
|
||||
.find(candidate => candidate.options.id === 'welcome-notice')!
|
||||
const injected = (
|
||||
entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
|
||||
)()
|
||||
|
||||
await injected.controller.load()
|
||||
expect(injected.controller.store.getSnapshot()).toEqual({
|
||||
status: 'ready', acknowledged: false, error: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushed invalidations', () => {
|
||||
@@ -164,8 +192,10 @@ describe('pushed invalidations', () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')
|
||||
.find(candidate => candidate.options.id === 'deepseek-official')!
|
||||
const injected = (
|
||||
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
|
||||
entry.inject as unknown as
|
||||
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
@@ -173,4 +203,25 @@ describe('pushed invalidations', () => {
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('routes only the onboarding namespace invalidation into welcome state', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')
|
||||
.find(candidate => candidate.options.id === 'welcome-notice')!
|
||||
const injected = (
|
||||
entry.inject as unknown as
|
||||
() => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
|
||||
)()
|
||||
injected.hooks.welcome.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2])
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -355,6 +355,65 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses the provider editor as a required credential-only onboarding form', async () => {
|
||||
let finishSet: ((response: RpcResponse<Record<string, never>>) => void) | undefined
|
||||
const set = vi.fn(() => new Promise<RpcResponse<Record<string, never>>>((resolve) => {
|
||||
finishSet = resolve
|
||||
}))
|
||||
const { face, mutate } = scriptedFace({ set })
|
||||
const onClose = vi.fn()
|
||||
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
||||
|
||||
render(<ProviderEditor
|
||||
provider="deepseek-official"
|
||||
displayName="DeepSeek"
|
||||
hideTitle
|
||||
namespace={wireNamespaces()[0]!}
|
||||
settingsPath={[]}
|
||||
api={face as never}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
credentialOnly
|
||||
credentialRequired
|
||||
autoFocusCredential
|
||||
cancelLabel="onboardingLater"
|
||||
submitLabel="onboardingSave"
|
||||
submitBusyLabel="onboardingSaving"
|
||||
onClose={onClose}
|
||||
/>)
|
||||
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const save = screen.getByText<HTMLButtonElement>(en.onboardingSave)
|
||||
expect(document.activeElement).toBe(key)
|
||||
expect(key.required).toBe(true)
|
||||
expect(save.disabled).toBe(true)
|
||||
expect(screen.getByText(en.onboardingLater)).toBeTruthy()
|
||||
expect(screen.queryByText(en.customized)).toBeNull()
|
||||
expect(screen.queryByLabelText(en.baseUrl)).toBeNull()
|
||||
|
||||
fireEvent.change(key, { target: { value: ' ' } })
|
||||
expect(screen.getByText(en.keyRequired)).toBeTruthy()
|
||||
expect(key.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(save.disabled).toBe(true)
|
||||
|
||||
fireEvent.change(key, { target: { value: ' sk-onboarding ' } })
|
||||
expect(screen.queryByText(en.keyRequired)).toBeNull()
|
||||
expect(save.disabled).toBe(false)
|
||||
fireEvent.click(save)
|
||||
|
||||
expect(await screen.findByText(en.onboardingSaving)).toBeTruthy()
|
||||
expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-onboarding' })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
if (finishSet === undefined) throw new Error('credential write did not start')
|
||||
await act(async () => {
|
||||
finishSet?.(ok({}))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
/** First-run DeepSeek prompt behavior over the shared Models join. */
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.getElementById('root')?.remove()
|
||||
})
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
@@ -22,6 +26,33 @@ function fail<T>(message: string): RpcResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
const DeepSeekConfig = Schema.object({
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().pattern(/^https:\/\//),
|
||||
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
||||
defaultContextWindow: Schema.number().step(1).min(1),
|
||||
models: Schema.array(Schema.object({
|
||||
id: Schema.string().required(),
|
||||
name: Schema.string(),
|
||||
description: Schema.string(),
|
||||
contextWindow: Schema.number().step(1).min(1),
|
||||
})),
|
||||
})
|
||||
|
||||
function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
|
||||
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
|
||||
return {
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value,
|
||||
base: value,
|
||||
user: {},
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
providerSettingsNs?: string
|
||||
@@ -33,9 +64,24 @@ function harness(options: {
|
||||
describeFailure?: string
|
||||
settingsWritable?: boolean
|
||||
providersReject?: boolean
|
||||
setFailure?: string
|
||||
setReject?: string
|
||||
} = {}) {
|
||||
if (document.getElementById('root') === null) {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
}
|
||||
let fileConfigured = false
|
||||
const configured = options.configured ?? (() => fileConfigured)
|
||||
const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv))))
|
||||
const set = vi.fn((_payload: { ref: string; value: string }) => {
|
||||
if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject))
|
||||
if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure))
|
||||
fileConfigured = true
|
||||
return Promise.resolve(ok({}))
|
||||
})
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => {
|
||||
@@ -56,19 +102,10 @@ function harness(options: {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: options.settingsWritable ?? true,
|
||||
namespaces: options.settingsNamespace === false
|
||||
? []
|
||||
: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
value: options.apiKeyEnv === null
|
||||
? {}
|
||||
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
hasDocument: false,
|
||||
namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
credentials: {
|
||||
describe: () => options.describeFailure === undefined
|
||||
@@ -84,6 +121,7 @@ function harness(options: {
|
||||
},
|
||||
}))
|
||||
: Promise.resolve(fail(options.describeFailure)),
|
||||
set,
|
||||
},
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
@@ -97,40 +135,91 @@ function harness(options: {
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
useModels: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
t: key => en[key],
|
||||
}
|
||||
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
|
||||
return {
|
||||
controller, complete, openSection, props, mutate, set,
|
||||
configure: () => { fileConfigured = true },
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeekOnboardingDialog', () => {
|
||||
it('loads on first entry and presents one accessible route to Models', async () => {
|
||||
it('renders when the shell root is absent', async () => {
|
||||
const h = harness()
|
||||
document.getElementById('root')!.remove()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
||||
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
|
||||
expect(action).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the Models section and dismisses the prompt', async () => {
|
||||
it('loads a credential-only modal, inerts the product, and focuses the key', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).toHaveBeenCalledWith('models')
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(document.getElementById('root')?.inert).toBe(true)
|
||||
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
await waitFor(() => { expect(document.activeElement).toBe(key) })
|
||||
expect(screen.queryByText(en.customized)).toBeNull()
|
||||
})
|
||||
|
||||
it('cannot be dismissed implicitly and restores the previous inert state', async () => {
|
||||
const h = harness()
|
||||
const appRoot = document.getElementById('root')!
|
||||
appRoot.inert = true
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(document.querySelector('[class*="mask"]')!)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
expect(appRoot.inert).toBe(true)
|
||||
})
|
||||
|
||||
it('requires a non-blank key before Save and continue is available', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
const save = screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave })
|
||||
expect(save.disabled).toBe(true)
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
||||
expect(save.disabled).toBe(true)
|
||||
expect(screen.getByText(en.keyRequired)).toBeTruthy()
|
||||
expect(h.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the modal open and reports rejected and failed credential writes', async () => {
|
||||
for (const [options, message] of [
|
||||
[{ setFailure: 'credential was rejected' }, 'credential was rejected'],
|
||||
[{ setReject: 'connection lost' }, 'connection lost'],
|
||||
] as const) {
|
||||
const h = harness(options)
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
expect(await screen.findByText(message)).toBeTruthy()
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(h.mutate).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows configure-later dismissal without opening settings', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('region')
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
expect(h.set).not.toHaveBeenCalled()
|
||||
expect(h.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not block the product when DeepSeek setup is unavailable', async () => {
|
||||
@@ -145,7 +234,7 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
@@ -160,7 +249,7 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
view.unmount()
|
||||
}
|
||||
@@ -169,10 +258,10 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
it('closes when an external credential invalidation refreshes the shared join', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('region')
|
||||
await screen.findByRole('dialog')
|
||||
h.configure()
|
||||
await act(async () => { await h.controller.load() })
|
||||
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, 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 { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.getElementById('root')?.remove()
|
||||
})
|
||||
|
||||
function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
const mutate = vi.fn(mutateImpl)
|
||||
const api = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
stepId: 'welcome-notice',
|
||||
complete,
|
||||
openSection: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useWelcome: bindSnapshotSelector(controller.store),
|
||||
t: key => zh[key],
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate, appRoot }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
it('uses the exact owner copy in both GUI locales', () => {
|
||||
expect(WELCOME_NOTICE_COPY.en).toEqual({
|
||||
title: 'Internal Testing Notice',
|
||||
body: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.",
|
||||
continueLabel: 'Continue',
|
||||
})
|
||||
expect(en.welcomeBody).toBe(WELCOME_NOTICE_COPY.en.body)
|
||||
expect(zh.welcomeBody).toBe(WELCOME_NOTICE_COPY.zh.body)
|
||||
})
|
||||
|
||||
it('renders one blocking modal action and focuses the title', async () => {
|
||||
const h = mount()
|
||||
const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
|
||||
expect(screen.getByText(paragraph, { exact: true })).toBeTruthy()
|
||||
}
|
||||
expect(dialog.querySelectorAll('p')).toHaveLength(2)
|
||||
expect(dialog.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
|
||||
expect(h.appRoot.inert).toBe(true)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(document.querySelector('[class*="mask"]')!)
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('completes only after the acknowledgement write commits', async () => {
|
||||
const h = mount()
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(h.mutate).toHaveBeenCalledOnce()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the sole action disabled while saving and reports a refused write', async () => {
|
||||
let resolveWrite!: (value: unknown) => void
|
||||
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
|
||||
const h = mount(undefined, () => write)
|
||||
await screen.findByRole('dialog')
|
||||
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
|
||||
fireEvent.click(action)
|
||||
expect(action.disabled).toBe(true)
|
||||
resolveWrite({
|
||||
rpcId: 'welcome-refused' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'settings-rejected',
|
||||
message: 'read only',
|
||||
details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(zh.welcomeError)
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
let rpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
||||
const describe = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
||||
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acknowledges only the exact current copy version', async () => {
|
||||
for (const [version, acknowledged] of [
|
||||
[undefined, false],
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(version)],
|
||||
}))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
settings: { describe: () => Promise.reject(new Error('offline string')) },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'settings-rejected',
|
||||
message: 'denied',
|
||||
details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
|
||||
},
|
||||
},
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
}))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
})))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory",
|
||||
"description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings",
|
||||
"description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -64,13 +64,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* Root-scoped onboarding steps contributed by settings features. The
|
||||
* shell mounts one ordered step at a time; the active registrant either
|
||||
* completes itself or keeps ownership until the user completes its sole
|
||||
* path. Registrants own readiness, copy, dialog behavior, AND the
|
||||
* takeover chrome: a step wraps its visible content in the
|
||||
* OnboardingSurface primitive (mask, opaque stage, `#root` inert) and
|
||||
* renders null while its private facts are still loading — the shell
|
||||
* paints no chrome of its own, so a mounted-but-deciding step shows and
|
||||
* blocks nothing (prevents a white flash on reload: a bare unwrapped step
|
||||
* would render without mask or stage).
|
||||
* path. Registrants own readiness, copy, dialog behavior, AND visible
|
||||
* chrome: a step wraps its visible content in its modal surface (including
|
||||
* `#root` inert ownership) and renders null while private facts are still
|
||||
* loading. The shell paints no chrome of its own, so a mounted-but-deciding
|
||||
* step shows and blocks nothing.
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-sidebar",
|
||||
"description": "Sidebar plugin: session multi-level tree, search, grouping, state dots",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-skill",
|
||||
"description": "Web skill references and the dedicated skill tool row",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-slots",
|
||||
"description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-subagent",
|
||||
"description": "Subagent conversation catalog, continuation routing UI, and '@' reference source",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-tool",
|
||||
"description": "Client Tool call-tree renderer and keyed per-tool presentation slot",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-user-questions",
|
||||
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-workflow-run",
|
||||
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-workspace",
|
||||
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
|
||||
return defineStore({
|
||||
init: (): WorkspaceViewState => ({
|
||||
groupBy: 'workspace',
|
||||
orderBy: 'manual',
|
||||
orderBy: 'updated',
|
||||
groupExpansion: {},
|
||||
sessionOrderByAccount: {},
|
||||
sessionUpdatedAtByAccount: {},
|
||||
|
||||
@@ -395,7 +395,7 @@ describe('createWorkspaceViewStore', () => {
|
||||
it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
expect(store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(store.getSnapshot().orderBy).toBe('manual')
|
||||
expect(store.getSnapshot().orderBy).toBe('updated')
|
||||
store.actions.setGroupBy('flat')
|
||||
store.actions.setOrderBy('updated')
|
||||
store.actions.setGroupExpanded('alpha', true)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
beforeEach(() => { localStorage.clear(); createWorkspaceViewStore().create().actions.setOrderBy('manual') })
|
||||
|
||||
// The seat's key domain is workspace ∪ common; the stub mirrors the real
|
||||
// lookup chain (namespace, then common vocabulary, then the key).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web-react",
|
||||
"description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web",
|
||||
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-code-runtime-worker-thread",
|
||||
"description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-code-runtime",
|
||||
"description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-compact",
|
||||
"description": "Human-facing slash command for explicit session compaction",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compaction-basic",
|
||||
"description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compaction-tool-result-pruner",
|
||||
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compaction",
|
||||
"description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-instructions",
|
||||
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-reference",
|
||||
"description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-time-context",
|
||||
"description": "Opt-in durable per-step context with the current time and elapsed time",
|
||||
"version": "0.0.1-rc.5",
|
||||
"version": "0.1.0-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user