mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(remote-access): 按评审修正访问门控边界,插件导航改为可选依赖
- ui-entry-customization、voice-input 不再把 pluginNavigation 当必需服务:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动
- plugin-manager 每次授权时刷新已加载的列表
- settings-account 账号设置分区与 sign-in 一并按 account/watch 门控,revision 恢复 master 原样
- schedule 会话头部提醒另按会话 useAccess('schedule/list') 渲染
- 导出 staticAccessPlugin;补 whenAccess 归属、撤权与读者页启动测试
- 同步 README、web-server 子系统文档、webserver 包描述与 Agent Note
This commit is contained in:
1 parent
09899f3a0f
commit
d5d43d931d
54 files changed
+469
-90
No files matched your search
@@ -6,8 +6,8 @@
|
||||
en: 2ef7cfef514c138a
|
||||
zh: 064e3aecbfafcd9c
|
||||
/agent-note-whenaccess-registration-that-follows-access/problem:
|
||||
en: c12d401db33f7d77
|
||||
zh: 3bc2c8ad1a441bd7
|
||||
en: 4f88d3ee7868eaa5
|
||||
zh: 72a4ce2dde13bef7
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
@@ -15,14 +15,14 @@
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 0487c2e2f5e71bde
|
||||
zh: 1750e83e7f9fa1cb
|
||||
en: 6f30d13790e8792f
|
||||
zh: fffeaac334b10e6a
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: 670e07d9da3ea599
|
||||
zh: 625e8e8bff57ff8c
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: 5c7c7c49446fd140
|
||||
zh: f8fa106040fac4bc
|
||||
en: de44fa92c49bc335
|
||||
zh: 34436fe971ab013f
|
||||
/agent-note-whenaccess-registration-that-follows-access/related:
|
||||
en: 89073ffbfcff0641
|
||||
zh: 736a180d13a08981
|
||||
@@ -13,7 +13,7 @@ Client plugins need to switch their own registrations on and off with the curren
|
||||
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
|
||||
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
|
||||
|
||||
#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change.
|
||||
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -56,17 +56,17 @@ export interface WhenAccess {
|
||||
|
||||
| Plugin | Change | Action |
|
||||
|---|---|---|
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` |
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`; the stream's disposer also ends this generation's bonus notices, which a frame of that stream begins, and the notice controller stays outside and begins again under the next grant | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change.
|
||||
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
|
||||
|
||||
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface WhenAccess {
|
||||
|
||||
| Consequence | Note |
|
||||
|---|---|
|
||||
| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged |
|
||||
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
|
||||
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
|
||||
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
|
||||
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
|
||||
|
||||
@@ -13,7 +13,7 @@ Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏
|
||||
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
|
||||
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。
|
||||
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -56,17 +56,17 @@ export interface WhenAccess {
|
||||
|
||||
| 插件 | 改法 | action |
|
||||
|---|---|---|
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` |
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`;账号流的 disposer 同时结束本轮奖励提示(提示由该流的帧开始),提示控制器留在外面,下次授权时重新开始 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。
|
||||
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface WhenAccess {
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 |
|
||||
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
|
||||
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
|
||||
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
|
||||
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
/http-server:
|
||||
en: c9b2f67d3248c496
|
||||
zh: 5fa36d2837ff4f63
|
||||
en: 56b268a69964f651
|
||||
zh: c8ac8f16bf58a98a
|
||||
/http-server/routes:
|
||||
en: e949c3738a35b58f
|
||||
zh: 138e3aaeb319ddc4
|
||||
@@ -12,5 +12,5 @@
|
||||
en: 4b162bd9f280eb06
|
||||
zh: 3bea98dfee88c582
|
||||
/http-server/the-service:
|
||||
en: cc36972b8458279f
|
||||
zh: c129044935d44769
|
||||
en: bbf84a6cd0460dc2
|
||||
zh: cc63ce45b0675c6d
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web-server.zh.md)
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web-server.md) | 中文
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
|
||||
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ interface TlsConfig {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 1e2172c756246031
|
||||
zh: f33dc7b40b14cceb
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it:
|
||||
en: bcba761168d2162c
|
||||
zh: d484e2305e074c0b
|
||||
en: f30bb7c6aae98fec
|
||||
zh: 1d28613c86531119
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation:
|
||||
en: f7a287740b4b08f9
|
||||
zh: 3347375c4e486b22
|
||||
|
||||
@@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p
|
||||
|
||||
Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change.
|
||||
|
||||
Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
|
||||
当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。
|
||||
|
||||
访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 76368b6b1da79c80
|
||||
zh: 52384aee991ac542
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
|
||||
en: 7cc90968634e3f7b
|
||||
zh: 54f58e17b14f7404
|
||||
en: e642513bcfd42d68
|
||||
zh: 38b9109ec017900d
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
|
||||
en: 3dcb1f29d03d5afe
|
||||
zh: 5ee39637636cd062
|
||||
|
||||
@@ -34,6 +34,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a
|
||||
|
||||
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
|
||||
|
||||
The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -34,6 +34,8 @@ kind: "package-reference"
|
||||
|
||||
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
|
||||
|
||||
「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */
|
||||
/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */
|
||||
import {
|
||||
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import { EntryPickerRow } from './EntryPickerRow.tsx'
|
||||
|
||||
@@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar'
|
||||
export interface EntryMenuInjected {
|
||||
/** Region of this registration; selects pin copy and the Creator prompt. */
|
||||
readonly region: EntryRegion
|
||||
readonly hooks: {
|
||||
/** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */
|
||||
readonly pluginNavigation: HostObservable<boolean>
|
||||
}
|
||||
/** Open the Plugins main list. */
|
||||
readonly browsePlugins: () => void
|
||||
/** Start a Creator draft with this region's prompt without sending it. */
|
||||
@@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'>
|
||||
* @returns Menu rows for the region's More menu.
|
||||
*/
|
||||
export function EntryMenuContent({
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t,
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t,
|
||||
}: EntryMenuContentProps) {
|
||||
const browsable = usePluginNavigation(present => present)
|
||||
return <>
|
||||
{entries.map((entry) => {
|
||||
const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`)
|
||||
@@ -39,11 +44,12 @@ export function EntryMenuContent({
|
||||
onOpen={() => { onDismiss(); onOpen(entry.id) }}
|
||||
onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} />
|
||||
})}
|
||||
<MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
{browsable && <MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
onSelect={() => { onDismiss(); browsePlugins() }}>
|
||||
{t('install')}
|
||||
</MenuItemButton>
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
</MenuItemButton>}
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} separatorBefore={!browsable && entries.length > 0}
|
||||
onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
{t('create')}
|
||||
</MenuItemButton>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Services used by the menu rows' footer actions. */
|
||||
export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace']
|
||||
/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */
|
||||
export const inject = ['slots', 'locale', 'uiWorkspace']
|
||||
|
||||
/**
|
||||
* Register the same menu rows into each region's More menu.
|
||||
* @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services.
|
||||
* @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja }))
|
||||
const t = ctx.locale.bind('entryCustomization')
|
||||
// The Plugins page provides its navigation only while the current Peer may use it.
|
||||
const pluginNavigation = createSnapshotStore(false)
|
||||
ctx.inject(['pluginNavigation'], (scope: Context) => {
|
||||
scope.effect(() => {
|
||||
pluginNavigation.set(true)
|
||||
return () => { pluginNavigation.set(false) }
|
||||
}, 'entry-customization: plugin navigation presence')
|
||||
})
|
||||
const injected = (region: EntryRegion): EntryMenuInjected => ({
|
||||
region,
|
||||
hooks: { pluginNavigation },
|
||||
browsePlugins: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' })
|
||||
ctx.pluginNavigation.openList()
|
||||
ctx.get('pluginNavigation')?.openList()
|
||||
},
|
||||
createPlugin: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' })
|
||||
|
||||
@@ -34,6 +34,7 @@ function content(overrides: Partial<ContentProps> = {}) {
|
||||
const props = {
|
||||
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
|
||||
region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused,
|
||||
usePluginNavigation: <T,>(select: (present: boolean) => T): T => select(true),
|
||||
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
|
||||
useResource: unused, useWorkspaces: unused,
|
||||
...callbacks,
|
||||
@@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
|
||||
</>
|
||||
}
|
||||
|
||||
async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) {
|
||||
async function bench(
|
||||
language: 'en' | 'zh' = 'en',
|
||||
{ analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {},
|
||||
) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtimes.add(runtime)
|
||||
const openList = vi.fn(), startSession = vi.fn(), track = vi.fn<TrackProductEvent>()
|
||||
@@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
locale.setLocale(language)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.slots.installLocale(locale)
|
||||
ctx.provide('pluginNavigation', { openList } as never)
|
||||
if (navigation) ctx.provide('pluginNavigation', { openList } as never)
|
||||
ctx.provide('uiWorkspace', { startSession } as never)
|
||||
if (analytics) ctx.provide('productAnalytics', { track } as never)
|
||||
},
|
||||
@@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
}
|
||||
|
||||
describe('More menu registrations', () => {
|
||||
it('shows plugin discovery only while the Plugins page navigation is present', async () => {
|
||||
const b = await bench('en', { navigation: false })
|
||||
const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy()
|
||||
// The Creator row takes the separator the absent discovery row would carry.
|
||||
expect(sidebar().getAllByRole('separator')).toHaveLength(1)
|
||||
const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void }
|
||||
expect(() => { injected.browsePlugins() }).not.toThrow()
|
||||
|
||||
const navigation = await b.runtime.mount({
|
||||
inject: [],
|
||||
apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) },
|
||||
})
|
||||
await act(async () => { await Promise.resolve() })
|
||||
fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' }))
|
||||
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
|
||||
|
||||
await navigation.dispose()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
})
|
||||
|
||||
it('registers one row set per region and removes all of them when the plugin unloads', async () => {
|
||||
const b = await bench()
|
||||
for (const seat of SEATS) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */
|
||||
import { expect } from 'vitest'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
|
||||
const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' })
|
||||
|
||||
withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization')
|
||||
}, 60_000)
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "../../client/ui-workspace" },
|
||||
{ "path": "../../client/ui-primitives" },
|
||||
{ "path": "../../client/ui-renderer" },
|
||||
{ "path": "../../client/ui-slots" }
|
||||
{ "path": "../../client/ui-slots" },
|
||||
{ "path": "../store" }
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
en: e0127a604ff4aebb
|
||||
zh: 38f1b3db30f81b22
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures:
|
||||
en: f060de7d9f44b058
|
||||
zh: 60770de36be6e90f
|
||||
en: e4807e47bca06517
|
||||
zh: 649a3f9f62771865
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation:
|
||||
en: 61260495346ceb3e
|
||||
zh: 32e05d3eb82fcaf2
|
||||
|
||||
@@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik
|
||||
|
||||
A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
|
||||
|
||||
Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
|
||||
|
||||
访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 54d7666c630da471
|
||||
zh: d7ded257d0fd5559
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
|
||||
en: 1de4fe390871d0da
|
||||
zh: 1723ae12ccbe3697
|
||||
en: fc469bab4fbd6028
|
||||
zh: 8f5a04e1b9a46471
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
|
||||
en: 203168fc87e939bd
|
||||
zh: 72d0ccd2c54f3f44
|
||||
|
||||
@@ -112,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a
|
||||
|
||||
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
|
||||
|
||||
Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -112,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
|
||||
|
||||
访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -111,6 +111,8 @@ export function apply(ctx: ClientContext): void {
|
||||
controller.details.reset()
|
||||
if (controller.getSnapshot().status !== 'idle') void controller.load()
|
||||
}
|
||||
// Host changes made while access was withdrawn reached no subscription below.
|
||||
refresh()
|
||||
yield ctx.remote.$on('plugin-manager/changed', refresh)
|
||||
yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) })
|
||||
yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) })
|
||||
|
||||
@@ -21,11 +21,53 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench(allows: (action: string) => boolean = () => true) {
|
||||
type AccessEffect = (() => void) | Iterable<() => void>
|
||||
|
||||
/** One guarded callback, the Context that owns it, and the disposer of its current installation. */
|
||||
interface Guard {
|
||||
readonly owner: Context
|
||||
readonly callback: () => AccessEffect
|
||||
dispose: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */
|
||||
class SwitchableAccess extends Service {
|
||||
private allowed = true
|
||||
private readonly guards: Guard[] = []
|
||||
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remoteAccess')
|
||||
}
|
||||
|
||||
whenAccess(_action: string, callback: () => AccessEffect): () => void {
|
||||
const guard: Guard = { owner: this.ctx, callback, dispose: undefined }
|
||||
this.guards.push(guard)
|
||||
this.install(guard)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
/** @param allowed - the answer every guarded effect follows from now on. */
|
||||
set(allowed: boolean): void {
|
||||
this.allowed = allowed
|
||||
for (const guard of this.guards) {
|
||||
if (allowed) this.install(guard)
|
||||
else { guard.dispose?.(); guard.dispose = undefined }
|
||||
}
|
||||
}
|
||||
|
||||
private install(guard: Guard): void {
|
||||
if (!this.allowed || guard.dispose !== undefined) return
|
||||
const dispose = guard.owner.effect(guard.callback)
|
||||
guard.dispose = () => { void dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined
|
||||
if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
class LocaleHolder extends Service {
|
||||
@@ -51,7 +93,7 @@ async function bench(allows: (action: string) => boolean = () => true) {
|
||||
ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal,
|
||||
toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() })
|
||||
await ctx.plugin(settings).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo }
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access }
|
||||
}
|
||||
|
||||
function declare(slots: SlotRegistry): () => void {
|
||||
@@ -157,6 +199,27 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => {
|
||||
const b = await bench('switchable')
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected: object = b.slots.entries('main')[0]!.inject!()
|
||||
const face = injected as PluginManagerFace
|
||||
face.ensure()
|
||||
await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
|
||||
expect(b.list).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.access!.set(false)
|
||||
expect(b.slots.entries('main')).toEqual([])
|
||||
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
|
||||
await Promise.resolve()
|
||||
expect(b.list).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.access!.set(true)
|
||||
expect(b.slots.entries('main')).toHaveLength(1)
|
||||
await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => {
|
||||
const allows = vi.fn(() => false)
|
||||
const b = await bench(allows)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 4c0dca55e4e9866d
|
||||
zh: 9ee6b565c7a81353
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
en: 47cd16c142e82385
|
||||
zh: 181849a3c7a167fd
|
||||
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
|
||||
en: 959145f9a08c3913
|
||||
zh: caae642a82d2acd3
|
||||
|
||||
@@ -57,6 +57,8 @@ The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchi
|
||||
|
||||
Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy.
|
||||
|
||||
Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ kind: "package-reference"
|
||||
|
||||
时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。
|
||||
|
||||
访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -31,8 +31,17 @@ export type ScheduleCatalogActionProps =
|
||||
const SECOND_MS = 1_000
|
||||
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
|
||||
/** Current-Session reminder catalog with durable deletion. */
|
||||
export function ScheduleCatalogAction({
|
||||
/**
|
||||
* Current-Session reminder catalog with durable deletion, rendered only while
|
||||
* the current Peer may list this Session's reminders.
|
||||
* @param props - the Session seats, the injected catalog and verbs, and localized copy.
|
||||
* @returns the reminder catalog action, or nothing without access.
|
||||
*/
|
||||
export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) {
|
||||
return props.useAccess('schedule/list') ? <SessionScheduleCatalog {...props} /> : null
|
||||
}
|
||||
|
||||
function SessionScheduleCatalog({
|
||||
useSession, useCatalog, onDelete, onRetry, openTaskDetail, t,
|
||||
}: ScheduleCatalogActionProps) {
|
||||
const openState = useSession(snapshot => snapshot.openState)
|
||||
|
||||
@@ -108,6 +108,7 @@ function props(
|
||||
records: readonly ScheduleRecord[] | undefined,
|
||||
openState: SessionSnapshot['openState'] = 'open',
|
||||
dictionary: typeof zh | typeof en = en,
|
||||
allowed = true,
|
||||
): ScheduleCatalogActionProps {
|
||||
const snapshot = sessionSnapshot(openState)
|
||||
const useSession = <T,>(select: (value: SessionSnapshot) => T): T => select(snapshot)
|
||||
@@ -120,6 +121,7 @@ function props(
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
useSession,
|
||||
useAccess: vi.fn((_action: string) => allowed),
|
||||
useCatalog,
|
||||
onDelete: vi.fn(async () => {}),
|
||||
onRetry: vi.fn(async () => {}),
|
||||
@@ -140,6 +142,19 @@ function metadataLines(): Element[] {
|
||||
}
|
||||
|
||||
describe('ScheduleCatalogAction visibility', () => {
|
||||
it('renders nothing and reads no catalog while the current Peer may not list this Session\'s reminders', () => {
|
||||
const refused = props([record('active', 'after', START + 60_000)], 'open', en, false)
|
||||
let catalogReads = 0
|
||||
const useCatalog: ScheduleCatalogActionProps['useCatalog'] = (select) => {
|
||||
catalogReads++
|
||||
return refused.useCatalog(select)
|
||||
}
|
||||
const view = render(<ScheduleCatalogAction {...refused} useCatalog={useCatalog} />)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
expect(refused.useAccess).toHaveBeenCalledWith('schedule/list')
|
||||
expect(catalogReads).toBe(0)
|
||||
})
|
||||
|
||||
it('hides the entry once a successful read proves there are no active reminders', () => {
|
||||
const active = [record('active', 'after', START + 60_000)]
|
||||
const view = render(<ScheduleCatalogAction {...props(active, 'cold')} />)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -768,6 +769,25 @@ describe('ui-session apply', () => {
|
||||
expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded)
|
||||
})
|
||||
|
||||
it('hangs Session-guarded effects on the calling plugin, so they leave when it unloads', async () => {
|
||||
const ctx = new Context()
|
||||
const bench = createSessionsBench(ctx)
|
||||
ctx.provide('sessions', bench.sessions)
|
||||
ctx.provide('slots', { provideRoot: vi.fn(), installScope: vi.fn(), bindStoreScope: vi.fn() } as never)
|
||||
new StaticRemoteAccess(ctx)
|
||||
apply(ctx)
|
||||
const dispose = vi.fn()
|
||||
const caller = ctx.plugin({
|
||||
inject: ['uiSession'],
|
||||
apply: (callerCtx: Context) => { callerCtx.uiSession.whenAccess(sessionId('s1'), 'session/fork', () => dispose) },
|
||||
})
|
||||
await caller.await()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
await caller.dispose()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the Host loader half inert', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 45aa7b44d5424d9b
|
||||
zh: 20c7d344c4e9bce0
|
||||
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
|
||||
en: c1c4db71fd09ca13
|
||||
zh: a40c0a01e9951945
|
||||
en: 3e5371e2bf7bd577
|
||||
zh: 91b1586963d9df58
|
||||
/deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation:
|
||||
en: 186b2046cfb5ef76
|
||||
zh: 57b042a618fb612e
|
||||
|
||||
@@ -63,6 +63,8 @@ The Desktop preload marker enables the introduction after account credentials ar
|
||||
|
||||
The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task.
|
||||
|
||||
Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. The owner always has access.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本
|
||||
|
||||
欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。
|
||||
|
||||
访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。owner 始终有权限。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -116,13 +116,16 @@ export function apply(ctx: Context): void {
|
||||
void request.finally(() => { if (refreshing === request) refreshing = undefined })
|
||||
return request
|
||||
}
|
||||
ctx.effect(() => () => { revision++ }, 'account: details request lifetime')
|
||||
// The account stream, and the reads it starts, follow access to this host's account state.
|
||||
ctx.remoteAccess.whenAccess('account/watch', function* () {
|
||||
yield () => { revision++ }
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
})
|
||||
let disposed = false
|
||||
// `notices.end()` disposes this generation's notice lifetime, which a frame
|
||||
// of this stream begins; the notice controller stays outside and begins
|
||||
// again under the next grant.
|
||||
yield () => { disposed = true; notices.end(); return stream.dispose() }
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
@@ -291,9 +294,9 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, DesktopOnboardingEntry))
|
||||
}
|
||||
ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
|
||||
name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountOnboarding))
|
||||
}, AccountOnboarding)))
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({
|
||||
name: 'shell.quota-notice', locale: 'settings.account',
|
||||
select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null,
|
||||
@@ -314,7 +317,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({
|
||||
name: 'settings.launcher', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountMenu)))
|
||||
ctx.slots.inject('settings.section', () => {
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.section', () => {
|
||||
let unregister: (() => void) | undefined
|
||||
const update = () => {
|
||||
if (snapshot.view?.status === 'credential-stored') {
|
||||
@@ -330,5 +333,5 @@ export function apply(ctx: Context): void {
|
||||
listeners.add(update)
|
||||
update()
|
||||
return () => { listeners.delete(update); unregister?.() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { apply } from '../src/client/index.ts'
|
||||
|
||||
afterEach(() => { vi.unstubAllEnvs() })
|
||||
|
||||
type AccessEffect = (() => void) | Iterable<() => void>
|
||||
|
||||
/** One `whenAccess` controller: its callback and the disposers of its current installation. */
|
||||
interface Guard {
|
||||
readonly action: string
|
||||
readonly install: () => AccessEffect
|
||||
installed: (() => void)[] | undefined
|
||||
}
|
||||
|
||||
it('withdraws every account surface and the account stream on revocation, and restores them on the next grant', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
|
||||
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
|
||||
const guards: Guard[] = []
|
||||
const grant = (): void => {
|
||||
for (const guard of guards) {
|
||||
const effect = guard.install()
|
||||
guard.installed = typeof effect === 'function' ? [effect] : [...effect]
|
||||
}
|
||||
}
|
||||
const revoke = (): void => {
|
||||
for (const guard of [...guards].reverse()) {
|
||||
for (const dispose of (guard.installed ?? []).reverse()) dispose()
|
||||
guard.installed = undefined
|
||||
}
|
||||
}
|
||||
const registered = new Map<string, number>()
|
||||
const streams: { readonly signal: AbortSignal; readonly accepted: Promise<undefined> }[] = []
|
||||
const lifetime: (() => void)[] = []
|
||||
const ctx = {
|
||||
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
|
||||
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
|
||||
configForms: {
|
||||
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
|
||||
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
|
||||
},
|
||||
slots: {
|
||||
inject: (_name: string, install: () => (() => void) | undefined) => {
|
||||
const dispose = install()
|
||||
return () => { dispose?.() }
|
||||
},
|
||||
register: (entry: { readonly name: string }) => {
|
||||
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
|
||||
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (action: string, install: () => AccessEffect) => {
|
||||
guards.push({ action, install, installed: undefined })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
|
||||
account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) },
|
||||
$stream: () => {
|
||||
const abort = new AbortController()
|
||||
const accepted = Promise.withResolvers<undefined>()
|
||||
streams.push({ signal: abort.signal, accepted: accepted.promise })
|
||||
return {
|
||||
signal: abort.signal,
|
||||
dispose: () => { abort.abort() },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { value: signedIn, accept: () => { accepted.resolve(undefined) } }
|
||||
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const surfaces = (): Record<string, number | undefined> => Object.fromEntries(
|
||||
['settings.launcher', 'shell.quota-notice', 'settings.models.sign-in', 'settings.section'].map(name => [name, registered.get(name)]),
|
||||
)
|
||||
try {
|
||||
apply(ctx as never)
|
||||
expect(new Set(guards.map(guard => guard.action))).toEqual(new Set(['account/watch']))
|
||||
grant()
|
||||
await streams[0]!.accepted
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 })
|
||||
|
||||
revoke()
|
||||
expect(streams[0]!.signal.aborted).toBe(true)
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 0, 'shell.quota-notice': 0, 'settings.models.sign-in': 0, 'settings.section': 0 })
|
||||
|
||||
grant()
|
||||
await streams[1]!.accepted
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 })
|
||||
} finally {
|
||||
revoke()
|
||||
for (const dispose of lifetime.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
@@ -78,13 +78,13 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => {
|
||||
withoutAccountAccess('opens no account stream and registers no account surface while the current Peer may not watch the account', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.mock.streams.end('account/watch')).toBe(0)
|
||||
expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0)
|
||||
expect(quotaNoticeEntry(c)).toBeUndefined()
|
||||
// The models sign-in entry lives inside the settings shell, which follows its own access.
|
||||
expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1)
|
||||
expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0)
|
||||
expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => {
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 597df68b5913e8d7
|
||||
zh: bc9114f3e877679a
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps:
|
||||
en: 27f0f6ec55f00228
|
||||
zh: 69b47ff1efd81c17
|
||||
en: ba55fe92bf49dc5a
|
||||
zh: 9a0e1345496a0ae6
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation:
|
||||
en: 1531b45780d0689f
|
||||
zh: 844548208c08a9c9
|
||||
|
||||
@@ -59,6 +59,8 @@ On a loopback browser, the shell renders **Open configuration file** only when t
|
||||
|
||||
The onboarding ledger projects in ascending order and mounts exactly one step at a time. 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. Visible steps own their dialog chrome and app-root `inert` lifecycle.
|
||||
|
||||
Access: the settings shell and the `settings.open` shortcut are registered only while the current Peer may call `settings/describe`. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
|
||||
引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。
|
||||
|
||||
访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { HostObservable, PropsRenderSlots, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { RemoteError, StaticRemoteAccess, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -56,7 +56,7 @@ const workspaceState = (
|
||||
|
||||
const ALLOWED: HostObservable<boolean> = createSnapshotStore(true)
|
||||
|
||||
async function bench() {
|
||||
async function bench(access: 'fake' | 'static' = 'fake') {
|
||||
const ctx = new Context()
|
||||
const commands = new Map<string, ShortcutCommand>()
|
||||
ctx.provide('shortcuts', {
|
||||
@@ -71,7 +71,8 @@ async function bench() {
|
||||
const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable<boolean> => ALLOWED)
|
||||
const stopGuard = vi.fn()
|
||||
const whenResourceAccess = vi.fn((): () => void => stopGuard)
|
||||
ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess })
|
||||
if (access === 'static') new StaticRemoteAccess(ctx)
|
||||
else ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess })
|
||||
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
|
||||
workspaceId: 'ws-new' as never,
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
@@ -241,6 +242,21 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded)
|
||||
})
|
||||
|
||||
it('hangs Workspace-guarded effects on the calling plugin, so they leave when it unloads', async () => {
|
||||
const b = await bench('static')
|
||||
onTestFinished(() => b.ctx.fiber.dispose())
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const dispose = vi.fn()
|
||||
const caller = b.ctx.plugin({
|
||||
inject: ['uiWorkspace'],
|
||||
apply: (callerCtx: Context) => { callerCtx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', () => dispose) },
|
||||
})
|
||||
await caller.await()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
await caller.dispose()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a default Workspace creation failure through the shared notice overlay', async () => {
|
||||
const b = await bench()
|
||||
onTestFinished(() => b.ctx.fiber.dispose())
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 364c80ee00a374ff
|
||||
/deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package:
|
||||
en: 4db1c67430cfa691
|
||||
zh: 484808eeff0d7692
|
||||
en: b7a99b656839e8a0
|
||||
zh: 5c6e2635eb20fc11
|
||||
/deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation:
|
||||
en: 8fe96eeef9afe7d7
|
||||
zh: 11936f647ded39eb
|
||||
|
||||
@@ -31,6 +31,8 @@ Use the native **Input device** selector in bundle details to follow the system
|
||||
|
||||
Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready.
|
||||
|
||||
`pluginNavigation` is optional: the plugin starts without it, and the setup dialog offers the details button only while the service is present.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。
|
||||
|
||||
`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -31,7 +31,12 @@ export interface VoiceInputActions {
|
||||
|
||||
/** Entry-injected Host readiness and microphone operations. */
|
||||
export interface VoiceInputInjected extends VoiceInputActions {
|
||||
hooks: { speechReadiness: HostObservable<SpeechReadiness>; microphoneDevice: HostObservable<MicrophoneDevice> }
|
||||
hooks: {
|
||||
speechReadiness: HostObservable<SpeechReadiness>
|
||||
microphoneDevice: HostObservable<MicrophoneDevice>
|
||||
/** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */
|
||||
pluginNavigation: HostObservable<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
/** Composer-owned expansion and draft actions; preferences stay in plugin settings. */
|
||||
@@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise<void> {
|
||||
|
||||
/** Render a compact microphone or an expanded capture, transcription, or retry row. */
|
||||
export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
|
||||
createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) {
|
||||
createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) {
|
||||
const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog
|
||||
const navigable = usePluginNavigation(present => present)
|
||||
const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId)
|
||||
const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby'
|
||||
|| provider?.preparation.phase === 'waking')
|
||||
@@ -153,7 +159,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
|
||||
</Tooltip>
|
||||
<VoiceSetupDialog open={setupOpen && !usable}
|
||||
needsInstallation={readiness.connected && provider?.location === 'host-local' && provider.preparation.phase === 'unprepared'}
|
||||
onDismiss={() => { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} />
|
||||
onDismiss={() => { setSetupOpen(false) }}
|
||||
onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} />
|
||||
</>
|
||||
return <div className={css.captureRow} data-voice-activity={phase}>
|
||||
<Button type="button" className={css.roundButton} size="sm" aria-label={t(pending ? 'discard' : 'cancel')}
|
||||
|
||||
@@ -5,8 +5,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
||||
import type { NS } from './locales.ts'
|
||||
|
||||
type VoiceSetupDialogProps = PropsLocale<typeof NS>
|
||||
& Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss' | 'onOpenDetails'>
|
||||
& { open: boolean; needsInstallation: boolean }
|
||||
& Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss'>
|
||||
& {
|
||||
open: boolean
|
||||
needsInstallation: boolean
|
||||
/** Open the plugin details; the details button is absent while it is undefined. */
|
||||
onOpenDetails: PropsRuntime<'plugins.bundle.activation'>['onOpenDetails'] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Guide activation or microphone clicks to the existing plugin details.
|
||||
@@ -18,7 +23,9 @@ export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDet
|
||||
closeLabel={t('cancel')} onClose={onDismiss}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onDismiss}>{t('setupPrompt.later')}</Button>
|
||||
<Button variant="primary" data-modal-autofocus onClick={onOpenDetails}>{t(needsInstallation ? 'setupPrompt.open' : 'setupPrompt.details')}</Button>
|
||||
{onOpenDetails !== undefined && <Button variant="primary" data-modal-autofocus onClick={onOpenDetails}>
|
||||
{t(needsInstallation ? 'setupPrompt.open' : 'setupPrompt.details')}
|
||||
</Button>}
|
||||
</>}>
|
||||
<p>{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}</p>
|
||||
</Modal>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx'
|
||||
import { Recording } from './audio.ts'
|
||||
import { createMicrophoneDeviceStore } from './microphone-device.ts'
|
||||
@@ -15,7 +16,7 @@ import { observeReadiness } from './readiness.ts'
|
||||
import { VoicePreparation } from './PreparationCard.tsx'
|
||||
import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx'
|
||||
|
||||
export const inject = ['remote', 'slots', 'locale', 'pluginNavigation']
|
||||
export const inject = ['remote', 'slots', 'locale']
|
||||
|
||||
function registerUi(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }))
|
||||
@@ -24,9 +25,17 @@ function registerUi(ctx: Context): void {
|
||||
const readiness = observeReadiness(ctx)
|
||||
ctx.effect(() => readiness.dispose)
|
||||
ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) })
|
||||
// The Plugins page provides its navigation only while the current Peer may use it.
|
||||
const pluginNavigation = createSnapshotStore(false)
|
||||
ctx.inject(['pluginNavigation'], (scope: Context) => {
|
||||
scope.effect(() => {
|
||||
pluginNavigation.set(true)
|
||||
return () => { pluginNavigation.set(false) }
|
||||
}, 'voice-input: plugin navigation presence')
|
||||
})
|
||||
const actions: VoiceInputInjected = {
|
||||
openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') },
|
||||
hooks: { speechReadiness: readiness.state, microphoneDevice: microphone },
|
||||
openSettings: () => { ctx.get('pluginNavigation')?.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') },
|
||||
hooks: { speechReadiness: readiness.state, microphoneDevice: microphone, pluginNavigation },
|
||||
selectMicrophone: (device) => { microphone.set(device) },
|
||||
createRecording: () => {
|
||||
const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id)
|
||||
@@ -62,7 +71,7 @@ function registerUi(ctx: Context): void {
|
||||
*/
|
||||
export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
|
||||
const disposeRemote = await ctx.remote.$mount(contribution)
|
||||
const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi)
|
||||
const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi)
|
||||
try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error }
|
||||
return async () => { await ui.dispose(); await disposeRemote() }
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function assertVoiceActions(value: Record<string, unknown>): asserts value is Re
|
||||
assert(typeof value.hooks === 'object' && value.hooks !== null)
|
||||
}
|
||||
|
||||
async function fixture(fail = false) {
|
||||
async function fixture(fail = false, navigation = true) {
|
||||
const ctx = new Context(), unmount = vi.fn(async () => {})
|
||||
class Remote extends Service {
|
||||
constructor() { super(ctx, 'remote') }
|
||||
@@ -45,7 +45,7 @@ async function fixture(fail = false) {
|
||||
}
|
||||
new Remote()
|
||||
const openBundle = vi.fn()
|
||||
ctx.provide('pluginNavigation', { openBundle })
|
||||
if (navigation) ctx.provide('pluginNavigation', { openBundle })
|
||||
const configure = vi.fn(async () => ({ ok: true, value: {} }))
|
||||
const prepare = vi.fn(async () => ({ ok: true, value: {} }))
|
||||
const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} }))
|
||||
@@ -63,6 +63,28 @@ async function fixture(fail = false) {
|
||||
return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe }
|
||||
}
|
||||
|
||||
it('starts without the Plugins page navigation and follows its presence', async () => {
|
||||
hostApply()
|
||||
const b = await fixture(false, false)
|
||||
try {
|
||||
await b.ctx.plugin({ inject: [...inject], apply: ctx => mountVoiceInput(ctx, REMOTE) })
|
||||
const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput)
|
||||
const actions = entry!.inject!()
|
||||
assertVoiceActions(actions)
|
||||
expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false)
|
||||
expect(() => { actions.openSettings() }).not.toThrow()
|
||||
const navigation = b.ctx.plugin({ apply: (ctx: Context) => { ctx.provide('pluginNavigation', { openBundle: b.openBundle } as never) } })
|
||||
await navigation
|
||||
await vi.waitFor(() => { expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(true) })
|
||||
actions.openSettings()
|
||||
expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle')
|
||||
await navigation.dispose()
|
||||
expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false)
|
||||
} finally {
|
||||
await b.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('withdraws its Remote, localized slot and microphone captures on disposal', async () => {
|
||||
hostApply()
|
||||
const b = await fixture()
|
||||
|
||||
@@ -18,7 +18,7 @@ beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.st
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() })
|
||||
const id = 'sensevoice-local' as SpeechProviderId
|
||||
const transcript: Transcript = { text: '检查 TypeScript 类型', audioSeconds: 2, inferenceSeconds: 0.4 }
|
||||
function fixture(recording?: Recording) {
|
||||
function fixture(recording?: Recording, navigable = true) {
|
||||
const capture = Object.assign(new Recording(() => {}), { start: vi.fn<Recording['start']>(async () => {}), stop: vi.fn(async () => new Uint8Array(48)),
|
||||
amplitude: () => 0, dispose: vi.fn(async () => {}) })
|
||||
const inputActions = { notify: vi.fn(), insertQuote: () => false,
|
||||
@@ -35,6 +35,7 @@ function fixture(recording?: Recording) {
|
||||
openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}),
|
||||
selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })),
|
||||
useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture,
|
||||
usePluginNavigation: bindSnapshotSelector(createSnapshotStore(navigable)),
|
||||
t: makeTranslate(zh, commonZh) }
|
||||
const view = render(<VoiceInput {...props} />)
|
||||
return { props, capture, inputActions, readiness, transcribe, view }
|
||||
@@ -335,6 +336,18 @@ it.each<SpeechPreparationState>([
|
||||
expect(b.props.openSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers no plugin details from setup guidance while the Plugins page navigation is absent', () => {
|
||||
const b = fixture(undefined, false), state = b.readiness.getSnapshot()
|
||||
act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [
|
||||
{ ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } },
|
||||
] } }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] }))
|
||||
const dialog = within(screen.getByRole('dialog'))
|
||||
expect(dialog.queryByRole('button', { name: zh['setupPrompt.open'] })).toBeNull()
|
||||
expect(dialog.getByRole('button', { name: zh['setupPrompt.later'] })).toBeTruthy()
|
||||
expect(b.props.openSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => {
|
||||
const b = fixture()
|
||||
render(<textarea aria-label="draft" defaultValue="Keep this draft" />)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
|
||||
"description": "Web route-registration plugin: HTTP and upgrade routes, per-Peer index transform taps, and static dist fallback",
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+1
-1
@@ -276,7 +276,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
|
||||
| `@deepseek-ai/dsh-host-open-in-app` | yes | Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes |
|
||||
| `@deepseek-ai/dsh-host-plugin-inventory` | no | Read-only Remote projection of current Cordis Loader plugin state |
|
||||
| `@deepseek-ai/dsh-host-product-telemetry-otel` | yes | Explicit product usage events exported through OpenTelemetry HTTP logs |
|
||||
| `@deepseek-ai/dsh-host-webserver` | yes | Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts |
|
||||
| `@deepseek-ai/dsh-host-webserver` | yes | Web route-registration plugin: HTTP and upgrade routes, per-Peer index transform taps, and static dist fallback |
|
||||
|
||||
## interaction
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ export type {
|
||||
FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer,
|
||||
} from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
export { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess } from './access.ts'
|
||||
export type { AccessStandardProps } from './access.ts'
|
||||
export { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess, staticAccessPlugin } from './access.ts'
|
||||
export type { AccessStandardProps, StaticAccessPlugin } from './access.ts'
|
||||
export { usePinnedBrowserLanguages } from './locale-env.ts'
|
||||
|
||||
/**
|
||||
|
||||
Generated
+3
@@ -4257,6 +4257,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-product-analytics':
|
||||
specifier: workspace:*
|
||||
version: link:../product-analytics
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
|
||||
Reference in new issue
Block a user