Merge master into application approval format
This commit is contained in:
commit
b63de2f36e
249 files changed
+8935
-715
No files matched your search
+4
-4
@@ -21,8 +21,8 @@
|
||||
en: 0a4240d88cca240a
|
||||
zh: 8f5f1527caf571cd
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
|
||||
en: 7dac236d6add3c3c
|
||||
zh: 2a43a923a0646bb0
|
||||
en: 15fb56befd328339
|
||||
zh: 0ffb10ada7e52805
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
|
||||
en: 37e2fa1fe5cb2b95
|
||||
zh: 80e513db87709cb4
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 94ca0d09e6fd1e3a
|
||||
zh: d5949a2bdec795ba
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
|
||||
en: 6a96d9ac06c222e5
|
||||
zh: b2814ab58348e831
|
||||
en: 16e1f2467c559315
|
||||
zh: a714ec6ff2722cf1
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
|
||||
en: b8f036f50cff7cd8
|
||||
zh: 6d623c4a1f4314b1
|
||||
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
@@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering
|
||||
|
||||
## Consequences
|
||||
|
||||
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
|
||||
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
|
||||
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
|
||||
|
||||
## Consequences
|
||||
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
|
||||
|
||||
|
||||
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: 47ba548b869be190
|
||||
zh: c2e5038a56fe741c
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
|
||||
en: 9e56d4bcbdc3a291
|
||||
zh: bebaaab338a15e40
|
||||
en: be0d17bfbd1b59d4
|
||||
zh: 7a23bccdf95c21ff
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
|
||||
en: ee5b0cd715ed1fef
|
||||
zh: 6b1478dcde21916d
|
||||
en: e7c66403fab991d2
|
||||
zh: 1580ec29116c7d42
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
|
||||
en: 6f0e04ef7924e2c6
|
||||
zh: e3730273e323440d
|
||||
en: 31ce6f02dd3bf39d
|
||||
zh: bcd29b2146f689b5
|
||||
+10
-2
@@ -10,12 +10,14 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
|
||||
`SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
|
||||
|
||||
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
|
||||
|
||||
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
|
||||
|
||||
Plugins compiled against the synchronous API keep running during migration. The awaitable registration is also the original Cordis effect disposer, so calling it releases the registration and a composing effect adopts its ownership; that call is deprecated. Registry-level `stateOf(session, key)` remains a deprecated read returning `unknown`; it loses access after the final release and logs one `ctx.logger.warn` per key per registry. Registration itself does not warn, because an ignored result is indistinguishable from one awaited later. A legacy plugin may never observe the registration, so the registry attaches its own rejection handler: once the current macrotask and its microtasks finish, a rejection nobody observed through `then`, `catch`, or `finally` is logged as an error while the plugin stays active, and one whose owner is no longer loading or active is dropped. TypeScript consumers migrate their declarations; the host state map is not restored.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
|
||||
@@ -26,6 +28,12 @@ AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. Ag
|
||||
|
||||
**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break.
|
||||
|
||||
**Require every external plugin to migrate immediately.** Compiled plugins fail at runtime on the missing disposer and keyed read, although the projection keys and state fields they read are unchanged.
|
||||
|
||||
**Return a new wrapper around the disposer.** Cordis composition adopts effects by their returned identity. A wrapper leaves the original registration owned by the fiber, so owner disposal can release it before the composed cleanup finishes.
|
||||
|
||||
**Dispose the plugin after an ignored rejection.** This approximates the old failed activation, but it needs a startup observer, runs only after the plugin's services became visible, and ends in `DISPOSED` rather than a retryable `FAILED`. Logging keeps the failure visible without a second lifecycle path.
|
||||
|
||||
## Consequences
|
||||
|
||||
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown.
|
||||
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
|
||||
+10
-2
@@ -10,12 +10,14 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
|
||||
`SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
|
||||
|
||||
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
|
||||
|
||||
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
|
||||
|
||||
按同步 API 编译的插件在迁移期间继续运行。可等待的注册结果同时就是原有的 Cordis effect 释放函数:调用它会释放注册,组合 effect 也会接管其所有权;该调用已弃用。注册表级 `stateOf(session, key)` 作为已弃用的读取保留,返回 `unknown`;最后一次释放后它失去访问,并且每个 registry 对每个键输出一次 `ctx.logger.warn`。注册本身不产生警告,因为被忽略的结果与稍后才等待的结果无法区分。旧插件可能从不观察注册结果,因此 registry 挂上自己的拒绝处理函数:当前宏任务及其微任务结束时,没有任何调用方通过 `then`、`catch` 或 `finally` 观察的拒绝会被记录为错误,插件保持激活;owner 已不在加载或激活状态的拒绝会被丢弃。TypeScript 消费方仍需迁移声明;宿主状态映射不再恢复。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
|
||||
@@ -26,6 +28,12 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`
|
||||
|
||||
**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。
|
||||
|
||||
**要求所有外部插件立即迁移。** 已编译的插件会因缺失的释放函数和按键读取在运行时失败,尽管它们读取的投影键与状态字段都没有变化。
|
||||
|
||||
**返回包装释放函数的新函数。** Cordis 组合按返回值身份接管 effect。包装函数会让原注册继续归 fiber 所有,因此 owner 卸载可能在组合清理结束前释放它。
|
||||
|
||||
**被忽略的拒绝出现后卸载插件。** 这近似于旧 API 的激活失败,但需要启动观察器,只能在插件服务已经可见后执行,并以 `DISPOSED` 而非可重试的 `FAILED` 结束。记录日志能让失败可见,而无需第二条生命周期路径。
|
||||
|
||||
## Consequences
|
||||
|
||||
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。
|
||||
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 972bbe45fe8ac28e
|
||||
zh: 593b851c142894e1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
|
||||
en: f44b784545c6d9a2
|
||||
zh: 3eb6fcb9f348dec4
|
||||
en: 913d1dceab36d4a1
|
||||
zh: 049521c5127f3478
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
|
||||
en: b8278678170f1507
|
||||
zh: 113a51ba5cf27de1
|
||||
|
||||
@@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su
|
||||
- `finish`: install the final room.
|
||||
|
||||
Content added during the batch updates the remaining movement by starting a new segment.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow.
|
||||
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
|
||||
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
|
||||
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
|
||||
|
||||
@@ -38,7 +38,7 @@ Status: implemented
|
||||
- `finish`:安装最终补白。
|
||||
|
||||
批次中途新增的内容按分段规则更新剩余运动。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。
|
||||
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
|
||||
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
|
||||
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md
|
||||
/agent-note-client-plugin-package-assets-served-by-url:
|
||||
en: 5505b948511a2d52
|
||||
zh: dae985d67e5c5935
|
||||
/agent-note-client-plugin-package-assets-served-by-url/problem:
|
||||
en: 159de908b09780eb
|
||||
zh: 8d15ca2f15b8846a
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision:
|
||||
en: 8ffcd355ec5e8271
|
||||
zh: 2932530bf783e3f7
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
|
||||
en: 704267953c123629
|
||||
zh: 860997024ad7825e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
|
||||
en: 485013b2b6778fa2
|
||||
zh: 7e5cb238bb2b8585
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/host-route:
|
||||
en: 151b7300f5b27a9f
|
||||
zh: fae9adff942c2ce8
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview:
|
||||
en: db1c7b2a0b916804
|
||||
zh: 0edf4123667d8e2e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
|
||||
en: eedff52d04763c50
|
||||
zh: 203bfcf81242b8fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration:
|
||||
en: 1e4d83d0de2d47c9
|
||||
zh: dc2c2876c73d3b1c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
|
||||
en: 98960e10634ee536
|
||||
zh: f0fdb0b0cdf9511c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/consequences:
|
||||
en: 898262f39c0243ae
|
||||
zh: e30f2161f5b669fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/testing:
|
||||
en: 8b6ce91df7c4c3a5
|
||||
zh: b149b71cef5be761
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client plugin package assets served by URL
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-client-plugin-package-assets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style.
|
||||
|
||||
The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image.
|
||||
|
||||
## Decision
|
||||
|
||||
A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset(<document-relative URL>)` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins/<package>/assets/<emitted path>`.
|
||||
|
||||
### References and file names
|
||||
|
||||
Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content:
|
||||
|
||||
```text
|
||||
src/client/assets/[email protected]
|
||||
lib/assets/[email protected]
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `<style>` text. Components need no hook, injected value, or service: the URL is a module constant computed when the module first runs.
|
||||
|
||||
The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) in client-modules owns the media-type table, the export key and target, the revision, the emitted name, and the URL, and the preset, the route, and the gates read them from there. The shared Client type root [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts), registered in [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json), declares each extension as a module with a string default export; a test keeps it equal to the table.
|
||||
|
||||
### Build
|
||||
|
||||
The `dsh-package-asset` plugin of the dynamic browser face resolves an import against its source location and fails the build when the file's real path is outside the package's `src/client/assets/` (which also rejects another package's or a dependency's image), when `exports["./assets/*"]` is not exactly `./lib/assets/*`, when the extension is unsupported, when a path segment is `node_modules` or contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve, or when the extension is not lowercase, as the import types are. It emits each file once per build and registers it as a watch input. The three CSS pipelines (CSS Modules, global, and `?inline`) rewrite relative `url()` values through a lightningcss visitor in stylesheets inside the package's own `src/`; `data:`, fragment, scheme, `/`-prefixed, and empty URLs stay unchanged, a relative file reference with a fragment or query fails the build, and dependency stylesheets are not rewritten. lightningcss does not call its `Url` visitor inside `image-set()`, so an `Image` visitor rewrites those options with the same rule. The rewrite writes an identifier placeholder that lightningcss never escapes, so a file name with parentheses or quotes still becomes its resolver call. The Node Client face of a dual-face package rejects asset imports. The browser face is the only writer of `lib/assets/`: a one-shot build deletes files it did not emit, and a watch rebuild keeps them, because the Host still serves the previous bundle generation and pages running it request those names.
|
||||
|
||||
### Host route
|
||||
|
||||
`bundleResource` matches `/plugins/<package>/assets/<path>` before the bundle tables, and such a path is answered by the asset route alone. For an active client row, the path is percent-decoded once and `createRequire(<package manifest>).resolve('<package>/assets/<path>')` applies the package's own exports, so unexported paths, `.` and `..` segments, missing files, and directories do not resolve. The media type comes from the resolved file's extension, because Node reads `#` and `?` in the subpath as URL syntax. Any resolution failure, an inactive package, and an unsupported type answer 404; the query selects nothing.
|
||||
|
||||
A served asset streams from disk on every request; the Host keeps neither its bytes nor a hash, and a request the browser cancels mid-stream ends without a warning. It carries the media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag` equal to the requested path, `x-content-type-options: nosniff`, and, for SVG, `content-security-policy: script-src 'none'`. An `If-None-Match` equal to the tag answers 304 without opening the file; `HEAD` answers without a body. `fetchBundle()` returns the same responses with a streamed body that closes the file when cancelled.
|
||||
|
||||
### WebWorker preview
|
||||
|
||||
The preview page runs its Host in a Worker, and a page request for a reference never reaches it. Before any Client bundle runs, the page asks the Worker for `GET /__assets__`, answered by tunnel glue like `GET /__boot__`: an uncompressed tar of every file under `lib/assets/` of each image package that declares `"./assets/*": "./lib/assets/*"`, named by its unencoded `plugins/<package>/assets/<path>`, which fits the ustar name limits the base image already met. The page encodes each entry name into its reference and turns the entry into a Blob URL with the media type of its extension, and its `resolveAsset` returns that URL, or the reference when the image holds no such file. The base image already carries these files, so no network request is added; each boot still walks the image's packages, archives every asset, and copies each entry into a Blob, enabled or not.
|
||||
|
||||
### Changes and package manifests
|
||||
|
||||
A rebuilt image produces a new file name in a rebuilt `client.js`, which reaches the browser through the existing bundle HMR; assets have no channel of their own. Client-modules reads a package manifest and the bundle metadata on every reconciliation, that is, on each Loader entry construction and disposal, instead of caching the manifest until restart, so an entry that restarts after a reinstall, upgrade, or move publishes its current declaration, client export, and manifest path; a reconciliation that finds the same manifest and bundle metadata leaves the published graph unchanged and reads no bundle bytes.
|
||||
|
||||
### Gates and migration
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) requires `lib/assets` in `files` when the export is declared. [`verify-client-packages`](../../../../scripts/verify-client-packages.ts) requires the exact export target, allows the export only on dynamic Web Client packages, and keeps the export and the package's references (imports, and `url()` in the stylesheets its Client sources import) consistent in both directions. [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) treats `./assets/*` as a non-code export. ui-settings-account and ui-chat moved to package assets, and ui-chat's tsdown plugin is gone.
|
||||
|
||||
Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, and workers), plugin metadata icons, detail-document images, and static-linked packages keep their own delivery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep data URLs.** No route is needed, but the bytes travel and parse with the JavaScript, and every package that styles with an image writes its own build plugin.
|
||||
|
||||
**A plain URL string with no runtime helper.** The served Web app needs nothing more, but the WebWorker preview page cannot request the URL, and changing it afterwards means copying the files to a static deployment or rewriting strings found in bundle text. A static copy is a build-time snapshot that bypasses the Host's rules and misses packages installed at runtime; finding strings in bundle text guesses at code. A synchronous call keeps the build output explicit and lets each carrier decide the URL.
|
||||
|
||||
**A Client Cordis service such as `ctx.assets.url(path)`.** Current consumers reference fixed files; a runtime lookup also cannot know the content revision without publishing a per-package asset list.
|
||||
|
||||
**Raw files in a package-root `assets/` without the build.** Authors could not preprocess them, so naming and format conversion would stay manual.
|
||||
|
||||
**The revision in a `?rev=` query, verified by the Host.** The Host must read a whole file to decide between 200 and 404, so a first request reads it twice and the Host must cache hashes by file metadata; a revision in the file name lets the Host only resolve and stream.
|
||||
|
||||
**The `client.js` revision as the asset revision.** That revision comes from the written bundle's metadata after the build, too late to appear in the bundle.
|
||||
|
||||
**Images in CSS only through JavaScript style objects.** Masks, backgrounds, and `@supports` fallbacks would move into components, while the URL is known at build time and the same rules rewrite `url()`.
|
||||
|
||||
**One plugin file protocol shared with detail documents.** Detail images read any package version from local, npm, or Git sources, need authentication because they trigger Host network access, and cache by source. Joining them would move authentication, source providers, and cache policy into client-modules; the Plugin Manager and Plugin Market detail routes stay separate until another consumer needs files of other packages.
|
||||
|
||||
**Manifests from Plugin Manager, or a manifest cache with change checks.** Plugin Manager lists bundles, not every client row, and resolves from the profile installation rather than the Loader resolution of the running entry. The failure was timing: a cache that outlived entry restarts. Rereading on each reconciliation and skipping unchanged results removes the cache instead of adding validation to it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The ui-settings-account `client.js` is 214 KB, and the account and ui-chat bundles contain no image data URLs; an image costs one same-origin request when shown and may appear after layout.
|
||||
- `require.asset` belongs to the bundle factory contract beside `require.async`; a package built outside this repository must emit the call. The WebWorker preview creates a Blob URL for every package asset of its image at boot, enabled or not, and keeps them for the page's lifetime.
|
||||
- The Desktop forwarder rewrites `/plugins/` cache headers to `no-store`, so Desktop reads an asset again on each display.
|
||||
- The Host does not compare file contents with the revision in the name: a file under `lib/assets/` edited in place keeps its immutable cache entries. Only the build writes that directory.
|
||||
- `require.resolve` runs synchronously on each request; a local measurement took about 43 µs for an existing file and 170 µs for a missing one. It reads exports through Node's package configuration cache, so an `./assets/*` export edited in place takes effect after boot HMR expires that package or the Host restarts. The route does not check the resolved file's real path, so a symbolic link under `lib/assets/` that leads outside the package is served.
|
||||
- A package outside this repository must reproduce the emitted-name rule and the `require.asset` call; the Client module system README records them.
|
||||
- A development session leaves earlier asset files under `lib/assets/` until the next one-shot build removes them; that pruning assumes the browser face is the directory's only writer.
|
||||
- An entry restart reads its manifest and stats its bundle; only a changed result reads the bundle and broadcasts a graph frame (about 30 KB for 69 rows). Editing only `package.json`, without an entry restart, still takes effect at the next entry or Host restart.
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) composes the real Web server and compares every answer with `fetchBundle()`: headers including the SVG policy, 304 without opening the file, `HEAD`, 405, multi-chunk streaming, stream closing on cancel, a browser cancellation that logs nothing, and 404 for excluded subpaths, missing exports and files, `..`, `%2e%2e`, encoded `..`, an empty path, malformed escapes, decoded `%` and `#`, unsupported types, directories, and inactive packages.
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) covers media types, the revision, emitted names, and references; [client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) keeps the type root equal to the table.
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering in a complete build, preserved `url()` forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files; [loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) covers the default and carrier resolvers.
|
||||
- The gate specs reject each invalid manifest or reference form, and [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) covers declarations, moved exports, and added or removed client declarations after an entry restart, an unchanged restart that publishes nothing and reads no bundle, and a chunk of a package named `@scope/assets`.
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) and [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) assert the package-asset URLs and decode the images in a browser; [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) decodes the ui-chat mask the WebWorker preview resolves to a Blob URL, and the runtime's [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) covers the archive and resolver.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client 插件包资源经 URL 提供
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-client-plugin-package-assets.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
动态 Client 插件的浏览器半侧由 [client 打包预设](../../../../packages/client/tsdown.client.ts) 打成 lazy-CJS factory,经 [Client 模块系统](../../../../packages/client/modules/README.zh.md) 的 `/plugins` 路由加载。这条路由过去只提供 `client.js` 和包内分片,所以插件代码引用的图片只能以 base64 data URL 打进 bundle,样式里用到的图片则要靠包级构建插件替换成内联样式。
|
||||
|
||||
不管图片显不显示,这些字节都随 JavaScript 一起下载和解析,base64 还会让体积大三分之一:引导插图使 ui-settings-account 的 `client.js` 达到约 3.4 MB。ui-chat 还为一张遮罩图维护了自己的 [tsdown 插件](../../../../packages/client/ui-chat/tsdown.config.ts)。
|
||||
|
||||
## Decision
|
||||
|
||||
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成对 bundle 模块加载器的同步调用 `require.asset(<文档相对 URL>)`;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
|
||||
|
||||
### 引用与文件名
|
||||
|
||||
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
|
||||
|
||||
```text
|
||||
src/client/assets/[email protected]
|
||||
lib/assets/[email protected]
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
路径逐段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
|
||||
|
||||
支持的扩展名是 `.svg`、`.png`、`.webp`、`.jpg` 和 `.jpeg`。client-modules 的 [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) 统一定义类型表、导出键与目标、revision、产出文件名和 URL,预设、路由和门禁都从这里读取。共享的 Client 类型根 [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts) 在 [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
|
||||
|
||||
### 构建
|
||||
|
||||
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段为 `node_modules` 或含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL;扩展名不是小写(导入类型只声明小写扩展名)。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包的样式表不改写。lightningcss 不会在 `image-set()` 内部调用 `Url` visitor,因此由 `Image` visitor 按同样的规则改写这些选项。改写时先写入 lightningcss 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
|
||||
|
||||
### Host 路由
|
||||
|
||||
`bundleResource` 先于 bundle 表匹配 `/plugins/<包名>/assets/<路径>`,这类路径只由资源路由回答。对启用中的 client 行,路径解码一次后,`createRequire(<包的 manifest>).resolve('<包名>/assets/<路径>')` 套用包自己的 exports,未导出路径、`.` 和 `..` 段、不存在的文件和目录都解析不出来。媒体类型按解析出的文件扩展名判断,因为 Node 会把子路径中的 `#` 和 `?` 当作 URL 语法。任何解析失败、未启用的包和不支持的类型都返回 404;查询串不参与选择。
|
||||
|
||||
被提供的资源每次请求都从磁盘流式读取,Host 既不保存字节也不保存 hash;浏览器中途取消的请求直接结束,不记 warning。响应带媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、等于请求路径的 `etag`、`x-content-type-options: nosniff`;SVG 另带 `content-security-policy: script-src 'none'`。`If-None-Match` 等于该 tag 时返回 304,且不打开文件;`HEAD` 不带 body。`fetchBundle()` 返回同样的响应,body 为流,取消时关闭文件。
|
||||
|
||||
### WebWorker 预览
|
||||
|
||||
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以未编码的 `plugins/<包名>/assets/<路径>` 命名,基础镜像已满足同样的 ustar 名称限制。页面把每个条目名编码为引用,并按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
|
||||
|
||||
### 变化与包 manifest
|
||||
|
||||
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
|
||||
|
||||
### 门禁与迁移
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) 要求声明导出时 `files` 含 `lib/assets`。[`verify-client-packages`](../../../../scripts/verify-client-packages.ts) 要求导出目标精确匹配,只允许动态 Web Client 包声明该导出,并要求导出与包内引用(import,以及其 Client 源码所 import 的样式表中的 `url()`)双向一致。[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 把 `./assets/*` 视为非代码导出。ui-settings-account 和 ui-chat 已迁移到包资源,ui-chat 的 tsdown 插件已删除。
|
||||
|
||||
三方库自己加载的资源(PDF.js 的字体、cmap、wasm 和 worker)、插件元数据图标、详情文档图片,以及 static-linked 包,保留各自的交付方式。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
|
||||
|
||||
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
|
||||
|
||||
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
|
||||
|
||||
**原始文件直接放在包根 `assets/`,不经构建。** 作者无法做预处理,命名和格式转换只能手工维护。
|
||||
|
||||
**revision 放在 `?rev=` 查询串,由 Host 校验。** Host 必须读完整个文件才能决定回 200 还是 404,首次请求要读两遍,还要按文件元数据缓存 hash;revision 写进文件名后,Host 只需解析路径并流式发送。
|
||||
|
||||
**用 `client.js` 的 revision 作为资源 revision。** 这个 revision 在构建写出 bundle 后才由文件元数据得出,来不及写进 bundle。
|
||||
|
||||
**CSS 只能经 JavaScript style 对象引用图片。** 遮罩、背景和 `@supports` 降级会被挪进组件,而 URL 在构建时就能确定,同一套规则也能改写 `url()`。
|
||||
|
||||
**与详情文档共用一套插件文件协议。** 详情图片要从本地、npm 或 Git 读取任意包版本,会触发 Host 网络访问因而需要鉴权,缓存方式也随来源而定。合并会把鉴权、来源提供方和缓存策略搬进 client-modules;在出现需要读取其他包文件的新使用方之前,Plugin Manager 和 Plugin Market 的详情路由保持独立。
|
||||
|
||||
**从 Plugin Manager 获取 manifest,或给 manifest 缓存加变化检查。** Plugin Manager 只列出 bundle,不覆盖所有 client 行,而且从 profile 安装目录解析,而不是用运行中条目的 Loader 解析。问题出在时机:缓存活得比条目重启更久。每次对账重新读取并跳过未变化的结果,是删掉缓存,而不是给缓存加校验。
|
||||
|
||||
## Consequences
|
||||
|
||||
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
|
||||
- `require.asset` 和 `require.async` 一样属于 bundle factory 契约;仓库外构建的包必须输出这个调用。WebWorker 预览在启动时为镜像里所有包资源创建 Blob URL,不论包是否启用,并在页面生命周期内保留。
|
||||
- Desktop 转发层把 `/plugins/` 的缓存头改写为 `no-store`,Desktop 每次显示都会重新读取资源。
|
||||
- Host 不比对文件内容与文件名中的 revision:原地修改 `lib/assets/` 下的文件会保留它的 immutable 缓存。这个目录只由构建写入。
|
||||
- `require.resolve` 在每次请求时同步执行;本机实测文件存在约 43 µs,不存在约 170 µs。它经 Node 的包配置缓存读取 exports,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包失效或 Host 重启后才生效。路由不检查解析结果的真实路径,所以 `lib/assets/` 下指向包外的符号链接会被提供。
|
||||
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
|
||||
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
|
||||
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) 组合真实 Web server,并将每个响应与 `fetchBundle()` 比对:包括 SVG 策略在内的响应头、不打开文件的 304、`HEAD`、405、多分片流式发送、取消时关闭流、浏览器取消请求时不记日志,以及以下情况的 404:被排除的子路径、缺少导出或文件、`..`、`%2e%2e`、编码后的 `..`、空路径、错误的转义、解码后的 `%` 和 `#`、不支持的类型、目录和未启用的包。
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) 覆盖媒体类型、revision、产出文件名和引用;[client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) 保证类型根与类型表一致。
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) 覆盖产出文件名、脚本和样式表文本中的解析调用、完整构建中它们的 CommonJS 渲染、保留的 `url()` 形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件;[loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) 覆盖默认解析和载体解析。
|
||||
- 各门禁的 spec 拒绝每种无效的 manifest 或引用形式;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 覆盖条目重启后的声明更新、导出移动、client 声明的增减、未变化的重启既不发布也不读取 bundle,以及名为 `@scope/assets` 的包的分片。
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) 和 [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) 断言包资源 URL,并在浏览器中解码这些图片;[preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 解码 WebWorker 预览解析为 Blob URL 的 ui-chat 遮罩图,运行时的 [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) 覆盖归档和解析。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-sdk-native-user-questions.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md
|
||||
/agent-note-sdk-native-user-questions:
|
||||
en: b506991bd07d87dc
|
||||
zh: 9f3e1ecb42163d72
|
||||
/agent-note-sdk-native-user-questions/problem:
|
||||
en: b3dbcdb4b5bce2d1
|
||||
zh: 60df882dc253fcf5
|
||||
/agent-note-sdk-native-user-questions/decision:
|
||||
en: 0f7b71f0ef58f5f8
|
||||
zh: 07bb12acad27d2fc
|
||||
/agent-note-sdk-native-user-questions/consequences:
|
||||
en: 46e3a450c6dce54e
|
||||
zh: 9f99efc6f8d2ab8d
|
||||
/agent-note-sdk-native-user-questions/alternatives-considered:
|
||||
en: 9861066ae00e7bde
|
||||
zh: 0dec00f4e3c692e0
|
||||
/agent-note-sdk-native-user-questions/verification:
|
||||
en: c16fb364fe8ec13b
|
||||
zh: fa23c162348ba682
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: SDK native user questions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-sdk-native-user-questions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The SDK can expose the shared `ask_user_question` tool without a Web GUI, but an out-of-process host cannot answer the native user-questions event through the SDK. Publishing the tool alone leaves calls without an answerer. The SDK must carry the interaction while the application chooses its user interface or simulated-user policy.
|
||||
|
||||
## Decision
|
||||
|
||||
The TypeScript and Python clients accept a question callback and advertise it during initialization. The SDK server adapts the existing user-questions seam to a bidirectional RPC only for its exact owned root Agents. Clients without the capability keep native in-process answerers. Tool definitions and the service's live-root and delegation checks remain shared with the desktop composition.
|
||||
|
||||
Question identity is independent of the JSON-RPC envelope so cancellation can identify outstanding host work. The runtime validates a complete answer batch, aborts pending requests when the tool or server stops, and ignores late responses. The host owns answer collection; the SDK neither chooses answers nor treats missing input as approval. The [protocol reference](../../../../packages/sdk/protocol/README.md#native-user-questions) owns wire details.
|
||||
|
||||
## Consequences
|
||||
|
||||
A GUI and runtime patch module are unnecessary. A host can use the same interface for a terminal, browser, or simulated user. The Python SDK keeps its synchronous API by running callbacks outside the reader thread; callbacks must observe cancellation and release their own resources. Late callbacks cannot publish into a later runtime process. Question waits are process-local; completed tool results retain ordinary Session persistence.
|
||||
|
||||
A Web Session controller is not an interaction provider for the SDK. Reminder restoration uses the SDK's own Session owner and initialized model options; it does not claim a second write handle through a GUI controller.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Requiring a Web GUI or mounting its controller couples headless execution to another lifecycle owner. Asking each host to inject a runtime patch repeats the transport bridge outside the maintained SDK. Removing the shared question tool would reduce the model's available capabilities rather than supply its missing answer path.
|
||||
|
||||
## Verification
|
||||
|
||||
Server tests cover ownership, native fallback, answer validation, cancellation, and transport failure. Client tests cover concurrent bidirectional transport and cancellation, including callback advertisement and answer delivery with either explicit startup or a direct `initialize()` call. Initialization starts the runtime before constructing the handshake capabilities so both entry paths advertise the installed callback. Python tests also cover reentrant requests and a late callback after process replacement. Real shipped-profile tests drive the built-in question in standard and PTC modes and close a runtime while the host is answering. The [server tests](../../../../packages/sdk/server/tests/user-questions.spec.ts), [client tests](../../../../packages/sdk/client/tests/user-questions.spec.ts), [Python tests](../../../../python/sdk/tests/test_user_questions.py), and [profile tests](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts) retain this evidence.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: SDK 原生用户问答
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-sdk-native-user-questions.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
SDK 可以在没有 Web GUI 时暴露共享的 `ask_user_question` 工具,但进程外宿主无法通过 SDK 回答原生用户问答事件。仅发布工具会使调用缺少回答方。SDK 必须承载交互,应用则自行选择用户界面或模拟用户策略。
|
||||
|
||||
## 决策
|
||||
|
||||
TypeScript 与 Python 客户端接收问答回调,并在初始化时声明。SDK server 仅为其确切拥有的根 Agent,把现有用户问答 seam 适配为双向 RPC。未声明能力的客户端保留原生进程内回答方。工具定义以及服务对存活根 Agent 和委派关系的检查仍与桌面端组合共享。
|
||||
|
||||
问题标识独立于 JSON-RPC 帧,使取消能够定位待决的宿主工作。运行时校验完整答案批次,在工具或服务器停止时中止待决请求,并忽略晚到响应。宿主拥有答案收集过程;SDK 不选择答案,也不把缺失输入当作批准。[协议参考](../../../../packages/sdk/protocol/README.zh.md#native-user-questions)拥有具体协议说明。
|
||||
|
||||
## 影响
|
||||
|
||||
此流程不要求 GUI 或运行时补丁模块。宿主可以用同一接口接入终端、浏览器或模拟用户。Python SDK 在读取线程之外执行回调,以保留同步 API;回调必须响应取消并释放自己的资源。晚到回调无法向后续新运行时进程发送结果。问题等待仅属于当前进程;已完成工具结果仍通过普通 Session 路径持久化。
|
||||
|
||||
Web Session controller 不能充当 SDK 的交互提供方。提醒恢复使用 SDK 自身的 Session 所有者和初始化模型选项,不通过 GUI 控制器争用第二个写入 handle。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
要求 Web GUI 或挂载其控制器,会将 headless 执行与另一个生命周期所有者耦合。要求各宿主注入运行时补丁,会在受维护的 SDK 外重复实现传输适配。移除共享问答工具只会减少模型可用能力,无法提供缺失的回答通道。
|
||||
|
||||
## 验证
|
||||
|
||||
服务器测试覆盖所有权、原生回退、答案校验、取消和传输失败。客户端测试覆盖并发双向传输和取消,包括显式启动和直接调用 `initialize()` 两条路径的回调声明与答案投递。初始化在构造握手能力之前启动运行时,使两条入口均声明已安装的回调。Python 测试还覆盖回调内再次请求,以及进程替换后的晚到回调。真实发行 profile 测试在标准模式和 PTC 模式调用内建问答,并在宿主回答期间关闭运行时。[服务器测试](../../../../packages/sdk/server/tests/user-questions.spec.ts)、[客户端测试](../../../../packages/sdk/client/tests/user-questions.spec.ts)、[Python 测试](../../../../python/sdk/tests/test_user_questions.py)和 [profile 测试](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts)保留这些依据。
|
||||
@@ -26,6 +26,9 @@
|
||||
/agent-note-frontend-large-session-performance-budgets/calibration/trajectory-readiness-polling:
|
||||
en: bdcfefd7358e335b
|
||||
zh: f718f66ca5944203
|
||||
/agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation:
|
||||
en: 2d2f78f4cf6aad8a
|
||||
zh: aec6af5f7c66de16
|
||||
/agent-note-frontend-large-session-performance-budgets/alternatives-considered:
|
||||
en: 4d2a4a64ccd14507
|
||||
zh: 8a4aaf4d4abea13d
|
||||
|
||||
@@ -86,6 +86,22 @@ A controlled macOS arm64 diagnostic on Node 24.19.0, Chromium 149.0.7827.55 and
|
||||
|
||||
Complete local 240-turn runs produce Trajectory samples of 157.795/146.737/148.571 ms before the observer change and 130.184/191.247/157.543 ms afterward, with medians 148.571 and 157.543 ms. Both runs pass every fixed endpoint budget; no local speedup or hosted calibration is claimed. The readiness regression rejects a hidden searchbox or record despite a visible unrelated table row; restoring the original selector waits makes that test fail.
|
||||
|
||||
### History selector invalidation
|
||||
|
||||
The older-history control is the immediate sibling before the `display: contents` flow anchor. Leading spacing therefore uses `.column > .older + [data-slot="conversation.chat.flow"]`, preserving the earlier selector's specificity and visible-row filtering. An ancestor `.column:has(> .older)` combined with the descendant empty-row `:has()` makes unrelated draft mutations invalidate the history subtree. A temporary Chromium invalidation trace identified this exact spacing selector and style recalculations covering 13,591–13,641 elements. The trace is causal evidence, not a latency measurement: tracing itself adds substantial overhead.
|
||||
|
||||
On Linux x64, Node 24.19.0 and Chromium 149.0.7827.55, separate three-sample runs of the unchanged browser benchmark measured these medians using the original and sibling selectors. Both retained all 25,409 DOM nodes and every trusted input overlapped the streaming reply.
|
||||
|
||||
| Endpoint | Original selector | Sibling selector |
|
||||
|---|---:|---:|
|
||||
| Draft typing | 517.39 ms | 378.50 ms |
|
||||
| First reply | 1556.89 ms | 801.58 ms |
|
||||
| Stream main-thread task | 2540.59 ms | 1774.75 ms |
|
||||
|
||||
These local runs both failed the independent open budget: their open medians were 1192.85 and 1666.99 ms. A separate six-sample diagnostic alternated the original and sibling selectors in the browser CSSOM before timing, using the same built artifacts. Its original/sibling open wall medians were 2598.99/1349.22 ms, while Chromium task medians were 325.67/305.90 ms. This finds no browser-computation regression in those samples, but does not explain the remaining wait or certify the whole benchmark. The alternating diagnostic measured typing medians of 350.18/362.82 ms, so the earlier typing improvement is not uniform across runs; first-reply medians were 1498.48/815.85 ms and stream-task medians 2449.69/1915.73 ms. The diagnostic changes are not shipped; workload, overlap assertions, budgets and benchmark source remain unchanged.
|
||||
|
||||
Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim.
|
||||
|
||||
@@ -86,6 +86,22 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol
|
||||
|
||||
完整本地 240 轮运行在修改观察器前得到 Trajectory 样本 157.795/146.737/148.571 ms,修改后为 130.184/191.247/157.543 ms,中位数分别为 148.571 和 157.543 ms。两次运行均通过所有固定终点预算;不声称本地提速或托管环境校准。即使存在可见的无关表格行,就绪回归测试仍拒绝隐藏的搜索框或记录;恢复原选择器等待会使该测试失败。
|
||||
|
||||
### 历史区域选择器失效
|
||||
|
||||
更早历史控件紧邻 `display: contents` 流锚点之前。因此首行间距使用 `.column > .older + [data-slot="conversation.chat.flow"]`,保持原选择器的优先级与可见行过滤。祖先 `.column:has(> .older)` 与后代空行 `:has()` 组合,会让无关的草稿修改使历史子树样式失效。临时 Chromium 失效跟踪定位到这条具体的间距选择器,样式重算覆盖 13,591–13,641 个元素。跟踪用于定位原因,不用于测量延迟:跟踪本身会增加大量开销。
|
||||
|
||||
在 Linux x64、Node 24.19.0 与 Chromium 149.0.7827.55 上,使用原选择器与兄弟选择器分别执行未修改的浏览器基准,每组取三个样本的中位数。两组都保留全部 25,409 个 DOM 节点,且每个受信任的输入都与流式回复重叠。
|
||||
|
||||
| 终点 | 原选择器 | 兄弟选择器 |
|
||||
|---|---:|---:|
|
||||
| 草稿键入 | 517.39 ms | 378.50 ms |
|
||||
| 首段回复 | 1556.89 ms | 801.58 ms |
|
||||
| 流式主线程任务 | 2540.59 ms | 1774.75 ms |
|
||||
|
||||
这两次本地运行都未通过独立的打开预算:打开中位数分别为 1192.85 与 1666.99 ms。另一项六样本诊断使用同一份构建产物,在计时前通过浏览器 CSSOM 交替使用原选择器与兄弟选择器。原版/修改版的打开壁钟中位数为 2598.99/1349.22 ms,Chromium 任务中位数为 325.67/305.90 ms。这些样本未观察到浏览器计算回归,但没有解释剩余等待,也不代表完整基准已通过。交替诊断的键入中位数为 350.18/362.82 ms,因此前述键入改善并非每次运行都能观察到;首段回复中位数为 1498.48/815.85 ms,流式任务中位数为 2449.69/1915.73 ms。诊断改动不随产品提交;负载、重叠断言、预算与基准源码保持不变。
|
||||
|
||||
强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Frame one text or tool response from the local Messages endpoint. */
|
||||
export function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
|
||||
return [
|
||||
{ type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: content },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
|
||||
{ type: 'message_stop' },
|
||||
].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Freeze only the isolated runtime's wall clock before reminder activation starts. */
|
||||
export function apply(ctx) {
|
||||
const original = Date.now
|
||||
const now = Number(process.env.DSH_TEST_SCHEDULE_NOW)
|
||||
ctx.effect(() => {
|
||||
Date.now = () => now
|
||||
return () => { Date.now = original }
|
||||
})
|
||||
ctx.provide('sdkTestClock', { ready: true })
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { execa } from 'execa'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
|
||||
import { messagesResponse } from './fixtures/messages-response.ts'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
|
||||
@@ -17,16 +19,6 @@ const launch = resolveExampleLaunch({
|
||||
})
|
||||
const decompress = promisify(zstdDecompress)
|
||||
|
||||
/** Frame one text or tool response from the local Messages endpoint. */
|
||||
function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
|
||||
return [
|
||||
{ type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: content },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
|
||||
{ type: 'message_stop' },
|
||||
].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
|
||||
}
|
||||
|
||||
function waitForLine(
|
||||
lines: string[],
|
||||
@@ -66,7 +58,11 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
{ label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
|
||||
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
|
||||
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
|
||||
])('$label', async ({ envValue, editorEnabled }) => {
|
||||
{ label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
])('$label', async (scenario) => {
|
||||
const { envValue, editorEnabled } = scenario
|
||||
const ptc = 'ptc' in scenario && scenario.ptc
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
|
||||
const editorPatch = join(root, 'editor.patch.yml')
|
||||
if (editorEnabled) await writeFile(editorPatch, [
|
||||
@@ -103,6 +99,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
DSH_HOME: join(root, '.dsh'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: ptc ? 'ptc' : 'native',
|
||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
|
||||
@@ -176,9 +173,23 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
const toolNames = tools.map(tool => tool.name)
|
||||
expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' })
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
expect(toolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search']))
|
||||
expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
|
||||
expect(toolNames).not.toContain('list_subagent_models')
|
||||
const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork',
|
||||
'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update']
|
||||
const requestText = JSON.stringify(modelRequests[0])
|
||||
if (ptc) {
|
||||
expect(toolNames).toEqual(['run_code'])
|
||||
for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true)
|
||||
} else {
|
||||
expect(toolNames).toEqual(expect.arrayContaining(expectedTools))
|
||||
expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
|
||||
expect(toolNames).not.toContain('list_subagent_models')
|
||||
}
|
||||
for (const text of ['Tokens prefixed with @', 'Prefer showing the primary results',
|
||||
'Time sampled while preparing']) {
|
||||
expect(requestText.includes(text), text).toBe(true)
|
||||
}
|
||||
expect(requestText).not.toContain('Current DeepSeek Harness implementation checkout:')
|
||||
expect(requestText).not.toContain('Current DeepSeek Harness Web GUI URL:')
|
||||
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
||||
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Reminder activation must share SDK ownership and initialized model limits after process restart. */
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { messagesResponse } from './fixtures/messages-response.ts'
|
||||
|
||||
it('delivers an overdue reminder with the SDK route and lets the client resume that same Session', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'sdk-schedule-resume-'))
|
||||
const patch = join(root, 'clock.patch.yml')
|
||||
const fixture = fileURLToPath(new URL('./fixtures/schedule-clock.mjs', import.meta.url))
|
||||
await writeFile(patch, [
|
||||
'- insert:', ' - id: sdk-test-clock', ` name: ${JSON.stringify(fixture)}`,
|
||||
'- id: sdk-jsonrpc-server', ' inject: [sdkAppStartup, loader, sdkTestClock]', '',
|
||||
].join('\n'))
|
||||
const requests: Record<string, unknown>[] = []
|
||||
const restored: Record<string, unknown>[] = []
|
||||
let restarting = false
|
||||
const server = createServer((incoming, response) => {
|
||||
let body = ''
|
||||
incoming.setEncoding('utf8')
|
||||
incoming.on('data', (chunk: string) => { body += chunk })
|
||||
incoming.on('end', () => {
|
||||
const request = JSON.parse(body) as Record<string, unknown>
|
||||
requests.push(request)
|
||||
if (restarting) restored.push(request)
|
||||
const content = requests.length === 1 ? {
|
||||
type: 'tool_use', id: 'create-reminder', name: 'schedule_create',
|
||||
input: { title: 'Check SDK', prompt: 'Report the SDK reminder', after_seconds: 86400 },
|
||||
} : { type: 'text', text: 'ready' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(messagesResponse(content, requests.length === 1 ? 'tool_use' : 'end_turn'))
|
||||
})
|
||||
})
|
||||
const clients: DeepSeekHarness[] = []
|
||||
onTestFinished(async () => {
|
||||
try { await Promise.all(clients.map(client => client.close())) } finally {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
})
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
|
||||
model: 'deepseek-v4-pro', maxTokens: 5678, initializeTimeoutMs: 30_000,
|
||||
env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-01T00:00:00Z')) } })
|
||||
clients.push(first)
|
||||
expect((await first.session('saved').run('Set a reminder')).finalResponse).toBe('ready')
|
||||
expect(requests).toHaveLength(2)
|
||||
await first.close()
|
||||
restarting = true
|
||||
const second = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
|
||||
model: 'deepseek-v4-flash', maxTokens: 1234, initializeTimeoutMs: 30_000,
|
||||
env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-03T00:00:00Z')) } })
|
||||
clients.push(second)
|
||||
await second.start()
|
||||
await vi.waitFor(() => { expect(restored).toHaveLength(1) }, { timeout: 30_000 })
|
||||
expect(restored[0]).toMatchObject({ model: 'deepseek-v4-flash', max_tokens: 1234 })
|
||||
const session = await second.resumeSession('saved')
|
||||
expect((await session.run('Continue after the reminder')).finalResponse).toBe('ready')
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Keyless SDK questions through the shipped profile, native tools, and both tool-call modes. */
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { messagesResponse } from './fixtures/messages-response.ts'
|
||||
|
||||
it.each([
|
||||
{ mode: 'native', cancel: false }, { mode: 'ptc', cancel: false }, { mode: 'native', cancel: true },
|
||||
])('handles a built-in question through $mode (cancel: $cancel)', async ({ mode, cancel }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'sdk-question-profile-'))
|
||||
const requests: Record<string, unknown>[] = []
|
||||
const questions = [{ id: 'format', question: 'Which format?', options: [{ label: 'CSV' }, { label: 'JSON' }] }]
|
||||
const server = createServer((incoming, response) => {
|
||||
let body = ''
|
||||
incoming.setEncoding('utf8')
|
||||
incoming.on('data', (chunk: string) => { body += chunk })
|
||||
incoming.on('end', () => {
|
||||
requests.push(JSON.parse(body) as Record<string, unknown>)
|
||||
const tool = mode === 'ptc' ? {
|
||||
type: 'tool_use', id: 'question-call', name: 'run_code', input: {
|
||||
code: `return await tools.ask_user_question(${JSON.stringify({ questions })});`, description: 'Ask which output format to use',
|
||||
},
|
||||
} : { type: 'tool_use', id: 'question-call', name: 'ask_user_question', input: { questions } }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(messagesResponse(requests.length === 1 ? tool : { type: 'text', text: 'answer received' },
|
||||
requests.length === 1 ? 'tool_use' : 'end_turn'))
|
||||
})
|
||||
})
|
||||
const state: { harness?: DeepSeekHarness } = {}
|
||||
onTestFinished(async () => {
|
||||
try { await state.harness?.close() } finally {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const seen: SdkUserQuestionRequest[] = []
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const harness = state.harness = new DeepSeekHarness({
|
||||
cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000,
|
||||
onUserQuestion: async (request, { signal }) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
seen.push(request)
|
||||
entered.resolve(signal)
|
||||
if (cancel) return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => { reject(new Error('question cancelled')) }, { once: true })
|
||||
})
|
||||
return { answers: [{ id: 'format', selected: ['CSV'] }] }
|
||||
},
|
||||
})
|
||||
const pending = harness.session('question-session').run('Ask which output format to use')
|
||||
if (cancel) {
|
||||
const settled = pending.catch(() => undefined)
|
||||
const signal = await entered.promise
|
||||
await harness.close()
|
||||
await settled
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(requests).toHaveLength(1)
|
||||
return
|
||||
}
|
||||
const result = await pending
|
||||
expect(seen).toEqual([{ requestId: expect.any(String) as string, sessionId: 'question-session', questions }])
|
||||
expect(result.finalResponse).toBe('answer received')
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.max_tokens).toBe(1234)
|
||||
expect(JSON.stringify(requests[1]?.messages)).toContain('CSV')
|
||||
expect(result.events.some(event => event.type === 'tool/result' && event.data.error !== undefined)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Scroll continuity when "On next message" folds a process group the reader opened. The latest Turn has
|
||||
* one Step; the reader opened its group and expanded the reasoning inside, so the group body is capped and
|
||||
* scrollable, then sends the next message from the floor. A held prompt keeps the new Turn from starting
|
||||
* until the fold settles, so only the fold moves the view while the scrollport is sampled on every
|
||||
* animation frame.
|
||||
*
|
||||
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
|
||||
* only: the fold never scrolls the view away and then snaps it back.
|
||||
* - A short answer leaves fold room below the tail. Growth fills that room without scrolling, so the running
|
||||
* status keeps its entrance slide when the new Step's first row appears instead of moving in one frame.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Route } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, openSettings } from './support.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
openFoldTrace?: { readonly frames: TraceFrame[]; readonly slides: number[]; stop(): void }
|
||||
}
|
||||
}
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])'
|
||||
/** Settled Turns before the latest one, so the transcript overflows the scrollport. */
|
||||
const TURNS = 7
|
||||
const NEXT_INPUT = 'OPENFOLD_NEXT_INPUT'
|
||||
const REPLY = 'OPENFOLD_REPLY'
|
||||
|
||||
/** One painted frame of the main scrollport. */
|
||||
interface TraceFrame {
|
||||
readonly top: number
|
||||
readonly floor: number
|
||||
/** Height of the fold room below the transcript. */
|
||||
readonly spacer: number
|
||||
/** The column marks the tail as pinned, which runs the status slide at zero duration. */
|
||||
readonly pinned: boolean
|
||||
/** The running status's top relative to the scrollport, when it is mounted. */
|
||||
readonly status: number | null
|
||||
readonly folding: boolean
|
||||
}
|
||||
|
||||
function paragraphs(marker: string, count: number, text: string): string {
|
||||
return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}`
|
||||
}
|
||||
|
||||
/** Settled history whose latest Turn is one Step: long reasoning, then an answer of `answerParagraphs`. */
|
||||
function seededLog(id: string, answerParagraphs: number): { log: string; search: string } {
|
||||
const base = createChatScrollFixture({ markerPrefix: `OPENFOLD${id.toUpperCase()}`, title: `OPENFOLD ${id}`, turns: TURNS })
|
||||
const parsed = parseSeedFixture(base.log)
|
||||
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === TURNS)
|
||||
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary)
|
||||
drafts.push(
|
||||
{ type: 'turn/start', data: { turn: TURNS } },
|
||||
{ type: 'step/start', data: { turn: TURNS, step: 1 } },
|
||||
{ type: 'user/message', data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'OPENFOLD_LAST_USER' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn: TURNS, step: 1, stream: [], message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: paragraphs('OPENFOLD_REASONING', 30, 'Reasoning weighs one more consideration, paragraph') },
|
||||
{ type: 'text', text: paragraphs('OPENFOLD_ANSWER', answerParagraphs, 'Final answer paragraph of the single Step') },
|
||||
],
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
} },
|
||||
{ type: 'step/end', data: { turn: TURNS, step: 1 } },
|
||||
{ type: 'turn/end', data: { turn: TURNS, reason: { kind: 'completed' } } },
|
||||
)
|
||||
return {
|
||||
log: [parsed.headerLine, ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n'),
|
||||
search: base.markers.user(1),
|
||||
}
|
||||
}
|
||||
|
||||
function reply(): ReplayEntry {
|
||||
const text = `${REPLY} short answer.`
|
||||
const finish: StreamChunk[] = [
|
||||
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
return { kind: 'chunks', chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
...finish,
|
||||
] }
|
||||
}
|
||||
|
||||
async function stableScroll(page: Page): Promise<void> {
|
||||
await page.evaluate(async (selector) => {
|
||||
const host = document.querySelector<HTMLElement>(selector)!
|
||||
let previous = host.scrollTop
|
||||
const started = performance.now()
|
||||
let stableSince = started
|
||||
await new Promise<void>((resolveStable, reject) => {
|
||||
const check = (): void => {
|
||||
const now = performance.now()
|
||||
const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null
|
||||
if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now
|
||||
previous = host.scrollTop
|
||||
if (now - stableSince >= 300) resolveStable()
|
||||
else if (now - started >= 20_000) reject(new Error(`scroll did not settle: top ${String(host.scrollTop)}`))
|
||||
else requestAnimationFrame(check)
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
})
|
||||
}, ROOT)
|
||||
}
|
||||
|
||||
async function toBottom(page: Page): Promise<void> {
|
||||
const button = page.locator(ROOT).getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
if (await button.count() > 0) await button.click()
|
||||
await stableScroll(page)
|
||||
await expect.poll(() => page.locator(`${ROOT} [data-chat-following-tail]`).count()).toBe(1)
|
||||
}
|
||||
|
||||
async function openSessionBySearch(page: Page, query: string): Promise<void> {
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query)
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
||||
await results.click()
|
||||
}
|
||||
|
||||
async function choosePreferences(page: Page): Promise<void> {
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
|
||||
await timing.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
|
||||
const details = dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
await details.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
}
|
||||
|
||||
/** Open the latest Turn's process group and expand its reasoning until the group body caps its height. */
|
||||
async function openLatestGroup(page: Page): Promise<void> {
|
||||
const group = page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS)}"]`).last()
|
||||
const header = group.locator('button[aria-expanded]').first()
|
||||
if (await header.getAttribute('aria-expanded') !== 'true') await header.click()
|
||||
const thought = group.locator('[data-variant="think"]').getByRole('button').first()
|
||||
if (await thought.getAttribute('aria-expanded') !== 'true') await thought.click()
|
||||
const body = group.locator('[data-step-process-body]')
|
||||
await expect.poll(() => body.evaluate(element => element.scrollHeight > element.clientHeight + 100)).toBe(true)
|
||||
// Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there.
|
||||
await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() })
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the scrollport on every animation frame, and record the duration of every status entrance slide
|
||||
* that a flow change starts.
|
||||
*/
|
||||
async function startTrace(page: Page): Promise<void> {
|
||||
await page.evaluate((selector) => {
|
||||
const host = document.querySelector<HTMLElement>(selector)!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
|
||||
const frames: TraceFrame[] = []
|
||||
const slides: number[] = []
|
||||
let frame = 0
|
||||
const tick = (): void => {
|
||||
const status = host.querySelector<HTMLElement>('[data-chat-running]')
|
||||
frames.push({
|
||||
top: host.scrollTop,
|
||||
floor: Math.max(0, host.scrollHeight - host.clientHeight),
|
||||
spacer: spacer.getBoundingClientRect().height,
|
||||
pinned: column.hasAttribute('data-chat-tail-pinned'),
|
||||
status: status === null ? null : status.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
||||
folding: column.hasAttribute('data-input-fold') || host.querySelector('[data-chat-motion="collapse"]') !== null,
|
||||
})
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
// The slide starts at the style update after the flow change; reading animations there sees its duration.
|
||||
const observer = new MutationObserver(() => {
|
||||
const status = host.querySelector<HTMLElement>('[data-chat-running]')
|
||||
for (const animation of status?.getAnimations() ?? []) {
|
||||
if (animation instanceof CSSAnimation && animation.animationName.includes('running-follows-output')) {
|
||||
slides.push(Number(animation.effect?.getComputedTiming().duration ?? 0))
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(column, { childList: true, subtree: true })
|
||||
window.openFoldTrace = { frames, slides, stop: () => { cancelAnimationFrame(frame); observer.disconnect() } }
|
||||
frame = requestAnimationFrame(tick)
|
||||
}, ROOT)
|
||||
}
|
||||
|
||||
async function readTrace(page: Page): Promise<{ frames: TraceFrame[]; slides: number[] }> {
|
||||
return page.evaluate(() => {
|
||||
const trace = window.openFoldTrace!
|
||||
return { frames: [...trace.frames], slides: [...trace.slides] }
|
||||
})
|
||||
}
|
||||
|
||||
/** Per-frame scroll movements larger than subpixel rounding. */
|
||||
function movements(frames: readonly TraceFrame[]): number[] {
|
||||
const steps: number[] = []
|
||||
for (let index = 1; index < frames.length; index++) {
|
||||
const step = frames[index]!.top - frames[index - 1]!.top
|
||||
if (Math.abs(step) > 1) steps.push(Math.round(step))
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
interface World {
|
||||
readonly scaffold: WebScaffold
|
||||
readonly page: Page
|
||||
readonly session: SessionId
|
||||
readonly tripwire: ReturnType<typeof watchConsole>
|
||||
/** Releases the held prompt; the new Turn starts after this. */
|
||||
readonly releasePrompt: () => void
|
||||
/** Releases the held model stream; the new Step's output arrives after this. */
|
||||
readonly releaseModel: () => void
|
||||
readonly ended: Promise<void>
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed and open the case, open the latest group, and send the next message from the floor with the prompt
|
||||
* and the model stream held.
|
||||
*/
|
||||
async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraphs: number): Promise<World> {
|
||||
const replayDir = await mkdtemp(join(tmpdir(), 'dsh-open-fold-'))
|
||||
await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify([reply()]))
|
||||
const scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride: join(replayDir, 'replay.override.json'),
|
||||
paceMs: 16,
|
||||
replayContextWindow: 10_000_000,
|
||||
})
|
||||
const model = Promise.withResolvers<undefined>()
|
||||
const disposeHold = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
await model.promise
|
||||
for await (const chunk of next()) yield chunk
|
||||
})
|
||||
const prompt = Promise.withResolvers<undefined>()
|
||||
const holdPrompt = async (route: Route): Promise<void> => {
|
||||
await prompt.promise
|
||||
await route.continue()
|
||||
}
|
||||
const page = await newEnglishPage(browser, 826)
|
||||
const close = async (): Promise<void> => {
|
||||
prompt.resolve(undefined)
|
||||
model.resolve(undefined)
|
||||
disposeHold()
|
||||
const failures: unknown[] = []
|
||||
await page.context().close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold.close().catch((error: unknown) => failures.push(error))
|
||||
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, `${id}: cleanup failed`)
|
||||
}
|
||||
try {
|
||||
const tripwire = watchConsole(page)
|
||||
await page.setViewportSize({ width: 1170, height: 826 })
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
const session = SessionId(`open-fold-${id}`)
|
||||
const { log, search } = seededLog(id, answerParagraphs)
|
||||
await seedSession(scaffold, log, session)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSessionBySearch(page, search)
|
||||
await page.locator(ROOT).getByText('OPENFOLD_ANSWER', { exact: false }).last().waitFor({ timeout: 30_000 })
|
||||
await choosePreferences(page)
|
||||
await toBottom(page)
|
||||
await openLatestGroup(page)
|
||||
await toBottom(page)
|
||||
await page.route('**/api/session/prompt', holdPrompt)
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => { off(); reject(new Error(`${id}: no turn/end within 60000ms`)) }, 60_000)
|
||||
const off = scaffold.ctx.on('session/event', (owner: { id: SessionId }, event: SessionEvent) => {
|
||||
if (owner.id !== session || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
// A failure before the release awaits nothing; the success path awaits and rethrows this rejection.
|
||||
ended.catch(() => undefined)
|
||||
await startTrace(page)
|
||||
await page.locator(`${ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT)
|
||||
await page.locator(ROOT).getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.locator(`${ROOT} [data-submission-echo]`).waitFor()
|
||||
await stableScroll(page)
|
||||
return {
|
||||
scaffold, page, session, tripwire, ended, close,
|
||||
releasePrompt: () => { prompt.resolve(undefined) },
|
||||
releaseModel: () => { model.resolve(undefined) },
|
||||
}
|
||||
} catch (error) {
|
||||
await close().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened process group', () => {
|
||||
let browser: Browser
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it('folds the opened group above a long answer without scrolling away and back', async () => {
|
||||
const world = await sendFromOpenedGroup(browser, 'long', 30)
|
||||
try {
|
||||
const { frames } = await readTrace(world.page)
|
||||
const steps = movements(frames)
|
||||
const reversals = steps.filter((step, index) => index > 0 && Math.sign(step) !== Math.sign(steps[index - 1]!)).length
|
||||
// Soft assertions let the held Turn finish, so a failure reports the curve instead of replay leftovers.
|
||||
expect.soft(frames.some(frame => frame.folding), 'the message folds the opened group').toBe(true)
|
||||
expect.soft(reversals, `scroll direction changes; per-frame movements ${JSON.stringify(steps)}`).toBe(0)
|
||||
const settled = frames.at(-1)!
|
||||
expect.soft(settled.floor - settled.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1)
|
||||
world.releasePrompt()
|
||||
world.releaseModel()
|
||||
await world.ended
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await world.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the running status slide while fold room below a short answer takes the new Step', async () => {
|
||||
const world = await sendFromOpenedGroup(browser, 'short', 2)
|
||||
try {
|
||||
const folded = (await readTrace(world.page)).frames.at(-1)!
|
||||
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
|
||||
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
|
||||
world.releasePrompt()
|
||||
// The running Step's group row is the first row after the user message; output stays held until then.
|
||||
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
|
||||
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
|
||||
await world.page.waitForTimeout(300)
|
||||
const { frames, slides } = await readTrace(world.page)
|
||||
const statusSteps = movements(frames.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
|
||||
.filter(step => Number.isFinite(step))
|
||||
// The fold runs status moves at zero duration on its own, so only settled frames show the pin's effect.
|
||||
const pinnedOverRoom = frames.filter(frame => !frame.folding && frame.pinned && frame.spacer > 0)
|
||||
expect.soft(pinnedOverRoom, 'settled frames pinned over fold room').toEqual([])
|
||||
expect.soft(slides.some(duration => duration > 0), `status slide durations ${JSON.stringify(slides)}; status movements ${JSON.stringify(statusSteps)}`).toBe(true)
|
||||
world.releaseModel()
|
||||
await world.ended
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await world.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -44,9 +44,54 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
await row.waitFor({ timeout: 20_000 })
|
||||
const trigger = row.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
|
||||
|
||||
const trailing = row.locator('[data-session-trailing]')
|
||||
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
|
||||
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
|
||||
const icons = target.locator('[data-session-trailing]')
|
||||
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
|
||||
const time = target.locator('[class*="time"]')
|
||||
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
|
||||
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
|
||||
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
|
||||
observations.push({
|
||||
stage,
|
||||
icons: await icons.filter({ visible: true }).evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')!)),
|
||||
time: await time.isVisible(),
|
||||
pinned: await target.getByRole('img', { name: 'Pinned', exact: true }).isVisible(),
|
||||
actions: await actions.isVisible(),
|
||||
})
|
||||
}
|
||||
await expect.poll(() => trailing.count()).toBe(2)
|
||||
expect(await trailing.evaluateAll(elements => elements.map(element => element.getAttribute('data-session-trailing'))))
|
||||
.toEqual([sessionId, sessionId])
|
||||
await page.mouse.move(0, 0)
|
||||
await observe('resting', true, false)
|
||||
await page.emulateMedia({ colorScheme: 'dark' })
|
||||
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(true)
|
||||
await observe('dark', true, false)
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(false)
|
||||
const titleWidth = await row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width)
|
||||
await row.hover()
|
||||
await trigger.focus()
|
||||
await observe('hover', false, true)
|
||||
await page.mouse.move(0, 0)
|
||||
await observe('pointer-left', true, false)
|
||||
await row.click()
|
||||
await expect.poll(() => page.locator('[data-composer-input][contenteditable="true"]')
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await row.click()
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await observe('clicked-current-row', true, false)
|
||||
expect(await row.locator('[data-slot="sidebar.session.row.leading"]').evaluate(element => element.tagName)).toBe('SPAN')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await observe('focus', false, true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await row.evaluate(element => element.querySelector('button') === document.activeElement)).toBe(true)
|
||||
await trigger.click()
|
||||
await observe('menu-open', false, true)
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor()
|
||||
expect(await menu.getByRole('separator').count()).toBe(1)
|
||||
@@ -56,7 +101,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
webSnapshotMode(),
|
||||
)
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Export session' }).click()
|
||||
await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
expect(await page.evaluate(() => ({
|
||||
action: document.documentElement.dataset.sessionAction,
|
||||
@@ -64,10 +109,87 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
title: document.documentElement.dataset.sessionActionTitle,
|
||||
}))).toEqual({ action: 'fixture.export-session', id: sessionId, title: SESSION_TITLE })
|
||||
|
||||
await page.getByRole('button', { name: 'Account menu', exact: true }).focus()
|
||||
await page.mouse.move(0, 0)
|
||||
await observe('blur', true, false)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
|
||||
await page.mouse.move(0, 0)
|
||||
await observe('narrow', true, false)
|
||||
const bounds = await row.evaluate((element) => {
|
||||
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
|
||||
const icons = [...element.querySelectorAll('[data-session-trailing]')].map(icon => icon.getBoundingClientRect())
|
||||
return {
|
||||
titleRight: title.right, firstIconLeft: icons[0]!.left, lastIconRight: icons.at(-1)!.right,
|
||||
rowRight: element.getBoundingClientRect().right,
|
||||
}
|
||||
})
|
||||
expect(bounds.titleRight).toBeLessThanOrEqual(bounds.firstIconLeft)
|
||||
expect(bounds.lastIconRight).toBeLessThanOrEqual(bounds.rowRight)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
const touchPage = await browser.newPage({
|
||||
viewport: { width: 390, height: 844 }, locale: 'en-US', isMobile: true, hasTouch: true,
|
||||
})
|
||||
try {
|
||||
const touchConsole = watchConsole(touchPage)
|
||||
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
|
||||
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
|
||||
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
|
||||
await touchTrigger.waitFor()
|
||||
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
|
||||
await observe('touch', true, true, touchRow)
|
||||
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
|
||||
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
|
||||
await touchTrigger.tap()
|
||||
await touchPage.getByRole('menu').waitFor()
|
||||
await observe('touch-menu-open', true, true, touchRow)
|
||||
await touchPage.keyboard.press('Escape')
|
||||
await observe('touch-menu-closed', true, true, touchRow)
|
||||
await touchTrigger.tap()
|
||||
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
|
||||
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
|
||||
await observe('touch-pinned', true, true, touchRow)
|
||||
const touchBounds = await touchRow.evaluate((element) => {
|
||||
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
|
||||
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
|
||||
const actions = element.querySelector('[class*="rowActions"]')!.getBoundingClientRect()
|
||||
return { titleRight: title.right, restingLeft: resting.left, restingRight: resting.right,
|
||||
actionsLeft: actions.left, actionsRight: actions.right, rowRight: element.getBoundingClientRect().right }
|
||||
})
|
||||
expect(touchBounds.titleRight).toBeLessThanOrEqual(touchBounds.restingLeft)
|
||||
expect(touchBounds.restingRight).toBeLessThanOrEqual(touchBounds.actionsLeft)
|
||||
expect(touchBounds.actionsRight).toBeLessThanOrEqual(touchBounds.rowRight)
|
||||
await touchTrigger.tap()
|
||||
await touchPage.getByRole('menuitem', { name: 'Archive session', exact: true }).tap()
|
||||
await expect.poll(() => touchRow.count()).toBe(0)
|
||||
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)).toBe(true)
|
||||
|
||||
await page.getByRole('button', { name: 'View options', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
|
||||
await row.click()
|
||||
await page.getByRole('alert').filter({ hasText: 'Archived sessions cannot be opened' }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await observe('clicked-archived-row', true, false)
|
||||
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
|
||||
await observe('touch-unarchived', true, true, touchRow)
|
||||
expect(touchConsole.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await touchPage.close()
|
||||
}
|
||||
scaffold.ctx.loader.remove(entryId)
|
||||
await expect.poll(() => page.evaluate(
|
||||
() => document.documentElement.dataset.liveDisposals,
|
||||
)).toBe('1')
|
||||
await expect.poll(() => trailing.count()).toBe(0)
|
||||
await expect.poll(() => row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width))
|
||||
.toBeGreaterThan(titleWidth)
|
||||
await compareOrRefreshGolden(
|
||||
join(EXPECTED, 'session-trailing.expected.json'),
|
||||
JSON.stringify(observations, null, 2),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
await row.hover()
|
||||
await trigger.click()
|
||||
await menu.waitFor()
|
||||
@@ -86,7 +208,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
expect(console.pageErrors).toEqual([])
|
||||
await assertFixtureInventory(EXPECTED, [
|
||||
'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md',
|
||||
'session-actions.expected.md',
|
||||
'session-actions.expected.md', 'session-trailing.expected.json',
|
||||
])
|
||||
} finally {
|
||||
await browser.close()
|
||||
|
||||
@@ -19,6 +19,7 @@ const MODE = webSnapshotMode()
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url))
|
||||
const NS = 'ui-settings-account'
|
||||
const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }
|
||||
const ACCOUNT_ASSET = /\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-[a-z-]+-[0-9a-f]{12}\.(?:svg|webp)$/
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => {
|
||||
let root: string
|
||||
@@ -124,15 +125,18 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
|
||||
async function snapshot(name: string): Promise<void> {
|
||||
await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false')
|
||||
const hasIllustration = name.startsWith('welcome') || name.startsWith('credit')
|
||||
if (hasIllustration) {
|
||||
const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements =>
|
||||
Promise.all(elements.map(async (element) => {
|
||||
const image = element as HTMLImageElement
|
||||
await image.decode()
|
||||
return image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
})))
|
||||
expect(decoded).toEqual([true, true])
|
||||
}
|
||||
// Illustrations and card icons are package assets fetched after the step renders.
|
||||
const images = await page.locator('[data-desktop-onboarding] img').evaluateAll(async elements =>
|
||||
Promise.all(elements.map(async (element) => {
|
||||
const image = element as HTMLImageElement
|
||||
await image.decode()
|
||||
return {
|
||||
source: image.currentSrc, illustration: image.matches('[class*="illustration"] > img'),
|
||||
loaded: image.naturalWidth > 0 && image.naturalHeight > 0,
|
||||
}
|
||||
})))
|
||||
expect(images.filter(image => !image.loaded || !ACCOUNT_ASSET.test(image.source)), `${name}: images outside loaded package assets`).toEqual([])
|
||||
expect(images.filter(image => image.illustration), `${name}: illustration images`).toHaveLength(hasIllustration ? 2 : 0)
|
||||
const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd)
|
||||
await mkdir(EXPECTED, { recursive: true })
|
||||
await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE)
|
||||
@@ -424,7 +428,8 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
|
||||
expect(await page.getByRole('button', { name: 'Next', exact: true })
|
||||
.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)')
|
||||
await snapshot('credit-en-dark')
|
||||
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml')
|
||||
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage))
|
||||
.toMatch(/^url\("[^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-back-[0-9a-f]{12}\.svg"\)$/)
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Got it', exact: true }).click()
|
||||
await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
[
|
||||
{
|
||||
"stage": "resting",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "dark",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "hover",
|
||||
"icons": [],
|
||||
"time": false,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "pointer-left",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "clicked-current-row",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "focus",
|
||||
"icons": [],
|
||||
"time": false,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "menu-open",
|
||||
"icons": [],
|
||||
"time": false,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "blur",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "narrow",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "touch",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "touch-menu-open",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "touch-menu-closed",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "touch-pinned",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": true,
|
||||
"actions": true
|
||||
},
|
||||
{
|
||||
"stage": "clicked-archived-row",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": false
|
||||
},
|
||||
{
|
||||
"stage": "touch-unarchived",
|
||||
"icons": [
|
||||
"Fixture badge",
|
||||
"Fixture section"
|
||||
],
|
||||
"time": true,
|
||||
"pinned": false,
|
||||
"actions": true
|
||||
}
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
|
||||
id: '@fixture/live-client',
|
||||
factory(require) {
|
||||
const React = require('react')
|
||||
const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
const { IconFolderCloseRegular, IconNewChatOutlineRegular, MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
const style = document.createElement('style')
|
||||
style.dataset.plugin = '@fixture/live-client'
|
||||
style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
|
||||
@@ -88,6 +88,18 @@ window.__ModuleLoader__.load({
|
||||
t(label),
|
||||
)
|
||||
})
|
||||
ctx.slots.inject('sidebar.session.row.trailing', function* () {
|
||||
for (const [id, order, Icon, label] of [
|
||||
['fixture.trailing-new-chat', 200, IconNewChatOutlineRegular, 'section'],
|
||||
['fixture.trailing-folder', 100, IconFolderCloseRegular, 'badge'],
|
||||
]) {
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.session.row.trailing', id, order, locale: 'fixtureLive',
|
||||
}, ({ sessionId, t }) => React.createElement('span', {
|
||||
role: 'img', 'aria-label': t(label), 'data-session-trailing': sessionId,
|
||||
}, React.createElement(Icon)))
|
||||
}
|
||||
})
|
||||
ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
|
||||
// The shipped rows end at Session ID (450); these follow as one group,
|
||||
// opened by the export row's hairline.
|
||||
|
||||
@@ -611,6 +611,21 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
|
||||
await cards.first().waitFor({ timeout: 30_000 })
|
||||
const listed = await cards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-plugin-package')))
|
||||
expect(listed.filter(name => name !== null && OPTIONAL_BUNDLES.includes(name)).length).toBeGreaterThan(0)
|
||||
// Package assets resolve from the Worker image: ui-chat's injected mask is a Blob URL that decodes.
|
||||
const mask = await page.evaluate(async () => {
|
||||
const styles = [...document.querySelectorAll('style[data-plugin="@deepseek-ai/dsh-client-ui-chat"]')]
|
||||
.map(style => style.textContent).join('\n')
|
||||
const url = /url\((blob:[^)]+)\)/u.exec(styles)?.[1]
|
||||
const unresolved = styles.includes('plugins/@deepseek-ai/dsh-client-ui-chat/assets/')
|
||||
if (url === undefined) return { url: null, width: 0, unresolved }
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode()
|
||||
return { url, width: image.naturalWidth, unresolved }
|
||||
})
|
||||
expect(mask.url).toMatch(/^blob:/u)
|
||||
expect(mask.unresolved).toBe(false)
|
||||
expect(mask.width).toBeGreaterThan(0)
|
||||
const result = await page.evaluate(async () => {
|
||||
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { message: string } } }
|
||||
const transport = (globalThis as typeof globalThis & {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeCo
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
|
||||
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
|
||||
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
|
||||
|
||||
class PausedReasoningAdapter extends LlmAdapter {
|
||||
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
|
||||
@@ -96,14 +98,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
|
||||
const imageSize = await animatedWhale.evaluate(async (element) => {
|
||||
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
|
||||
const imageSize = await animatedWhale.evaluate(async (element, mask) => {
|
||||
const source = getComputedStyle(element).maskImage.match(new RegExp(mask))?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the ui-chat package asset URL')
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
return [image.naturalWidth, image.naturalHeight]
|
||||
})
|
||||
}, WHALE_MASK.source)
|
||||
expect(imageSize).toEqual([28, 28])
|
||||
expect(await restingWhale.isVisible()).toBe(false)
|
||||
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
"tests/fold-blank-matrix.e2e.ts",
|
||||
"tests/chat-fold-opened-group.e2e.ts",
|
||||
"tests/chat-flow-layout.e2e.ts",
|
||||
"tests/layout-bottom.e2e.ts",
|
||||
"tests/shortcuts-desktop.e2e.ts",
|
||||
|
||||
@@ -2799,8 +2799,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts)
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -4714,7 +4714,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
@@ -4764,6 +4764,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
|
||||
@@ -2801,8 +2801,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts)
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -4716,7 +4716,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
@@ -4766,6 +4766,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
|
||||
@@ -62,6 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
@@ -81,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
@@ -83,7 +84,7 @@
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
+17
-11
@@ -240,6 +240,7 @@ flowchart TD
|
||||
pkg_deepseek_account_platform["deepseek-account-platform"]
|
||||
end
|
||||
subgraph group_deliverables["packages/deliverables"]
|
||||
pkg_deliverable_file_references["deliverable-file-references"]
|
||||
pkg_tool_present["tool-present"]
|
||||
pkg_workspace_changes["workspace-changes"]
|
||||
end
|
||||
@@ -502,6 +503,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_deliverable_file_references --> pkg_system_prompt
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
@@ -1361,6 +1363,7 @@ flowchart TD
|
||||
pkg_sdk_protocol --> pkg_llm
|
||||
pkg_sdk_protocol --> pkg_session
|
||||
pkg_sdk_protocol --> pkg_subagent
|
||||
pkg_sdk_protocol --> pkg_user_questions
|
||||
pkg_workflow_ptc --> pkg_agent
|
||||
pkg_workflow_ptc --> pkg_llm
|
||||
pkg_workflow_ptc --> pkg_ptc_runtime
|
||||
@@ -1392,15 +1395,6 @@ flowchart TD
|
||||
pkg_sdk_client --> pkg_llm
|
||||
pkg_sdk_client --> pkg_sdk_protocol
|
||||
pkg_sdk_client --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
pkg_client_test_runtime --> pkg_api_gateway
|
||||
pkg_client_test_runtime --> pkg_api_session_controller
|
||||
pkg_client_test_runtime --> pkg_api_workspace_controller
|
||||
@@ -1430,6 +1424,17 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_schedule
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED module-graph:packages -->
|
||||
@@ -1597,6 +1602,7 @@ flowchart TD
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
|
||||
@@ -1772,13 +1778,13 @@ flowchart TD
|
||||
| [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
+17
-11
@@ -242,6 +242,7 @@ flowchart TD
|
||||
pkg_deepseek_account_platform["deepseek-account-platform"]
|
||||
end
|
||||
subgraph group_deliverables["packages/deliverables"]
|
||||
pkg_deliverable_file_references["deliverable-file-references"]
|
||||
pkg_tool_present["tool-present"]
|
||||
pkg_workspace_changes["workspace-changes"]
|
||||
end
|
||||
@@ -504,6 +505,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_deliverable_file_references --> pkg_system_prompt
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
@@ -1363,6 +1365,7 @@ flowchart TD
|
||||
pkg_sdk_protocol --> pkg_llm
|
||||
pkg_sdk_protocol --> pkg_session
|
||||
pkg_sdk_protocol --> pkg_subagent
|
||||
pkg_sdk_protocol --> pkg_user_questions
|
||||
pkg_workflow_ptc --> pkg_agent
|
||||
pkg_workflow_ptc --> pkg_llm
|
||||
pkg_workflow_ptc --> pkg_ptc_runtime
|
||||
@@ -1394,15 +1397,6 @@ flowchart TD
|
||||
pkg_sdk_client --> pkg_llm
|
||||
pkg_sdk_client --> pkg_sdk_protocol
|
||||
pkg_sdk_client --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
pkg_client_test_runtime --> pkg_api_gateway
|
||||
pkg_client_test_runtime --> pkg_api_session_controller
|
||||
pkg_client_test_runtime --> pkg_api_workspace_controller
|
||||
@@ -1432,6 +1426,17 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_schedule
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED module-graph:packages -->
|
||||
@@ -1599,6 +1604,7 @@ flowchart TD
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
|
||||
@@ -1774,13 +1780,13 @@ flowchart TD
|
||||
| [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -9,11 +9,11 @@
|
||||
en: 96b95cdb015c1af6
|
||||
zh: 01e4144d91551c13
|
||||
/client-modules/the-scan:
|
||||
en: 91ce66fab388a8a2
|
||||
zh: 77428943582ae3f6
|
||||
en: 811c0e8a3a370399
|
||||
zh: 4a49a3999d35503a
|
||||
/client-modules/the-bundle-route-and-index-injection:
|
||||
en: d653be500d3a0d67
|
||||
zh: fffb1901ce8ba7d9
|
||||
en: 82c089fbd9ffd535
|
||||
zh: 30ac52d01b4fc074
|
||||
/client-modules/the-service:
|
||||
en: 000882ed7f09ad3f
|
||||
zh: c17c05f087d9574d
|
||||
en: b1df1bf2b8ee3ad1
|
||||
zh: 6373d1b8cae22d02
|
||||
@@ -95,11 +95,11 @@ A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional
|
||||
|
||||
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
|
||||
|
||||
Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
|
||||
Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`.
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. `/plugins/<package>/assets/<path>` serves a package's published images; the [package README](../../packages/client/modules/README.md#package-assets) owns that route. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` returns the same responses as the HTTP route: the lazy bundle responses, and package assets read on each request. `rebuilt(id)` publishes a bundle rebuilt without an entry restart: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies; an entry restart publishes through reconciliation instead. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
|
||||
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -95,11 +95,11 @@ interface WebBootGraph {
|
||||
|
||||
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
|
||||
|
||||
包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
|
||||
每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。`/plugins/<package>/assets/<path>` 提供包发布的图片,该路由由[包 README](../../packages/client/modules/README.zh.md#package-assets) 说明。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 返回与 HTTP 路由相同的响应:惰性的 bundle 响应,以及每次请求都读取的包资源。`rebuilt(id)` 发布没有伴随条目重启的 bundle 重建:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知;条目重启则经对账发布。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
|
||||
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
en: 077aa6573d9ede1a
|
||||
zh: 0acea592a6e3630a
|
||||
/host-wide-schedule/host-delivery:
|
||||
en: b6d522d24b9b97ba
|
||||
zh: 84df8ea09bedf51c
|
||||
en: d5fc5b853529b139
|
||||
zh: 4cc5eba14f9660e6
|
||||
@@ -431,7 +431,7 @@ New tasks initialize an empty history. Older task rows without history expose on
|
||||
|
||||
## Host delivery
|
||||
|
||||
The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session through the Session controller, including cold Sessions. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch.
|
||||
The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session, including cold Sessions, through `schedule/resolve-agent`. The embedding owns activation: the SDK handler waits for initialization and retains its Session handle and model options; without a handler, the Web Session controller restores the Session. The composition waits for its activation provider before starting Schedule. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch.
|
||||
|
||||
Plugin-sourced `followup()` synchronously appends the message to the Session inbox. After a successful Session flush, one task-record put appends a saved delivery with its instruction snapshot and stores the latest `ScheduleDeliveryReceipt` together with the one-shot's `inactive` status or the recurring task's next target and status. Recurring tasks in the same batch share the receipt's `messageId`; each receipt retains that task's delivered occurrence time. Delivery never steers or cancels the current turn and does not wait for model completion.
|
||||
|
||||
@@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
<a id="scheduleresolve-agent--waterfall"></a>
|
||||
|
||||
#### `schedule/resolve-agent` — waterfall
|
||||
|
||||
Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Restore a reminder's Session through its embedding's owning controller.
|
||||
* Call next() to use the Web Session controller.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise<Agent>): Promise<Agent>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -431,7 +431,7 @@ type ScheduleDeliveryHistoryResult =
|
||||
|
||||
## 宿主投递
|
||||
|
||||
宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递时通过 Session controller 获取原 Session,包括尚未加载的 Session。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。
|
||||
宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递通过 `schedule/resolve-agent` 解析原 Session,包括尚未激活的会话。嵌入方拥有激活过程:SDK 处理方等待初始化,并保留其 Session handle 和模型选项;没有处理方时由 Web Session controller 恢复会话。组合等待其激活提供方就绪后再启动 Schedule。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。
|
||||
|
||||
插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。
|
||||
|
||||
@@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
<a id="scheduleresolve-agent--waterfall"></a>
|
||||
|
||||
#### `schedule/resolve-agent` — waterfall
|
||||
|
||||
Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Restore a reminder's Session through its embedding's owning controller.
|
||||
* Call next() to use the Web Session controller.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise<Agent>): Promise<Agent>
|
||||
```
|
||||
|
||||
Types: [Agent](core.zh.md) · [SessionId](core.zh.md)
|
||||
|
||||
Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
|
||||
/session-projections:
|
||||
en: e9330151d3eb579a
|
||||
zh: 53e47acd1d810da9
|
||||
en: 45e5f8cb8ca18d6b
|
||||
zh: 4ecfad40ec9e221c
|
||||
/session-projections/the-unit:
|
||||
en: d48814d3ab0e997f
|
||||
zh: d0337d8eba097a63
|
||||
|
||||
@@ -26,6 +26,8 @@ interface ProjectionHandle<S> {
|
||||
}
|
||||
```
|
||||
|
||||
The [compatibility rules](../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) define legacy disposal, keyed reads, and their once-per-key deprecation warnings.
|
||||
|
||||
## The unit
|
||||
|
||||
`ProjectionDefinition<K, S>` carries each unit's host state type locally; registration infers `S` from `stateSchema` and returns `ProjectionHandle<S>`. Only client-visible keys merge into `SessionProjectionMap`, which types their whole values. A `wire` block makes a unit client-visible, and rendering belongs to the slot system, never this layer:
|
||||
@@ -314,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
* Retained handles remain readable after release.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
@@ -323,7 +325,7 @@ register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDe
|
||||
* and always checkpointed like every other unit.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ interface ProjectionHandle<S> {
|
||||
}
|
||||
```
|
||||
|
||||
[兼容规则](../../packages/session/session-projection/README.zh.md#deprecated-plugin-compatibility)规定旧释放操作、按键读取及其每个键一次的弃用警告。
|
||||
|
||||
## 投影单元
|
||||
|
||||
`ProjectionDefinition<K, S>` 在各单元内部携带宿主状态类型;注册从 `stateSchema` 推导 `S`,并返回 `ProjectionHandle<S>`。只有客户端可见的 key 合并到 `SessionProjectionMap`,由它约束对应的全量值。`wire` 块使单元对客户端可见,渲染归 slot 体系管,永远不归本层:
|
||||
@@ -314,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
* Retained handles remain readable after release.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
@@ -323,7 +325,7 @@ register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDe
|
||||
* and always checkpointed like every other unit.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
|
||||
/web-client-slots:
|
||||
en: 8d33b164230344fa
|
||||
zh: 13a930ab96280744
|
||||
en: 5f160332e4980284
|
||||
zh: b1ae1da451a6209e
|
||||
/web-client-slots/declaration-and-lifecycle:
|
||||
en: f2d2c0d3b8f14ec7
|
||||
zh: 441ed13ff3f81d2a
|
||||
|
||||
@@ -10,6 +10,8 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
|
||||
|
||||
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
|
||||
|
||||
[ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior.
|
||||
|
||||
## Declaration and lifecycle
|
||||
|
||||
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
|
||||
|
||||
@@ -10,6 +10,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
|
||||
|
||||
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
|
||||
|
||||
[ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。
|
||||
|
||||
## 声明与生命周期
|
||||
|
||||
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Session projection registration is asynchronous and returns a retained reader instead of a disposer."
|
||||
---
|
||||
# Session projection registration handles
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise<ProjectionHandle<S>>`. Registry-level `stateOf(session, key)` and `SessionProjectionStateMap` are removed. Host state types stay local to `ProjectionDefinition<K, S>` and are inferred from `stateSchema` during registration. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand.
|
||||
|
||||
`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`. Consumers of domain data inject the owning service.
|
||||
2. Remove host-state map augmentations and imports; pass the local state type as the second `ProjectionDefinition<K, S>` argument. Keep client-value declarations in `SessionProjectionMap`. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports.
|
||||
3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise<void>`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures.
|
||||
4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`.
|
||||
5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Session 投影注册改为异步,并返回保留读取能力的句柄而不是释放函数。"
|
||||
---
|
||||
# Session 投影注册句柄
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise<ProjectionHandle<S>>`。注册表级 `stateOf(session, key)` 与 `SessionProjectionStateMap` 已移除。宿主状态类型保留在各自的 `ProjectionDefinition<K, S>` 中,注册时从 `stateSchema` 推导。注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。
|
||||
|
||||
`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。领域数据的消费方注入所属服务。
|
||||
2. 删除宿主状态映射的声明合并与导入,将局部状态类型作为 `ProjectionDefinition<K, S>` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。
|
||||
3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise<void>` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。
|
||||
4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。
|
||||
5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md
|
||||
/:
|
||||
en: d3cffa64b9c50498
|
||||
zh: 515c03ce9ae6d031
|
||||
/sdk-desktop-tools-and-context:
|
||||
en: d202616bf6e03fa4
|
||||
zh: cc276adda4b952be
|
||||
/sdk-desktop-tools-and-context/change:
|
||||
en: 85ac2825c52e2503
|
||||
zh: fd6fdbdca131c778
|
||||
/sdk-desktop-tools-and-context/migration:
|
||||
en: 9caef4f8b153622a
|
||||
zh: 1b4901f5b18b72fd
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: The existing SDK profile includes native desktop tools and model context.
|
||||
---
|
||||
|
||||
# SDK desktop tools and context
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The existing `sdk` profile now includes `present`, `ask_user_question`, schedule tools, file-reference guidance and time context. It does not start a Web server. Standard tool calling remains the default; `DSH_TOOLS_MODE=ptc` selects programmatic tool calling in the same profile. `sdk-minimal` is unchanged.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Keep selecting `sdk`. Provide `onUserQuestion` (TypeScript) or `on_user_question` (Python) to answer built-in questions over the native protocol, or retain an in-process `user-questions/request` answerer; without one, the tool reports `NO_PROVIDER`.
|
||||
2. Working directory and model still come from SDK initialization. The SDK profile does not mount the Web-specific checkout and GUI development context.
|
||||
3. Custom compositions that need the previous model-visible tool/context roster can disable the `present`, `tool-ask-user`, `tool-schedule`, `file-reference-local`, `deliverable-file-references`, and `time-context` rows. Review any pinned prompts and tool schemas before upgrading.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: 现有 SDK profile 包含原生桌面工具和模型上下文。
|
||||
---
|
||||
|
||||
# SDK 桌面工具与上下文
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
现有 `sdk` profile 现在包含 `present`、`ask_user_question`、定时任务工具、文件引用说明和时间上下文。它不启动 Web server。默认仍使用标准工具调用;`DSH_TOOLS_MODE=ptc` 在同一 profile 中选择程序化工具调用。`sdk-minimal` 保持不变。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 继续选择 `sdk`。通过 `onUserQuestion`(TypeScript)或 `on_user_question`(Python)使用原生协议回答内建问题,或保留进程内的 `user-questions/request` 回答方;没有回答方时,工具报告 `NO_PROVIDER`。
|
||||
2. 工作目录和模型仍来自 SDK 初始化。SDK profile 不挂载 Web 专用的 checkout 和 GUI 开发上下文。
|
||||
3. 需要保留之前模型可见工具/上下文组合的自定义配置,可以禁用 `present`、`tool-ask-user`、`tool-schedule`、`file-reference-local`、`deliverable-file-references` 和 `time-context` 配置项。升级前请检查固定的提示词和工具 schema。
|
||||
+9
-9
@@ -1,16 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/session-projection-handles/guide.md
|
||||
/:
|
||||
en: b3f54a7eadb3398a
|
||||
zh: 6815b996b2cebc6f
|
||||
en: a7c158942ce2a575
|
||||
zh: 7103da2fa7ca494a
|
||||
/session-projection-registration-handles:
|
||||
en: 5cf8cb0051860df5
|
||||
zh: 2578c6c3e77bc1c7
|
||||
en: 08af78abac6d3bcd
|
||||
zh: 85ede5596cac9d5e
|
||||
/session-projection-registration-handles/change:
|
||||
en: 64c2492c43c7a263
|
||||
zh: ba5a15b959df496c
|
||||
en: d5e6ea606de638a0
|
||||
zh: a041eee1ffd48ac3
|
||||
/session-projection-registration-handles/migration:
|
||||
en: bd4310bb946b6f08
|
||||
zh: 2086e6e9c11bc4ed
|
||||
en: 9c7d73f9f50c4bdd
|
||||
zh: b1c597c63d52b444
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Session projection registration resolves asynchronously to a retained reader; the callable result and registry-level state reads are deprecated."
|
||||
---
|
||||
# Session projection registration handles
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise<ProjectionHandle<S>>`. The definition installs synchronously; awaiting the result yields a handle with `stateOf(session)` and `dispose()`, and registration errors reject it. The runtime value is a callable thenable, not a native Promise: calling it still releases the registration, but that call is deprecated. `typeof registration` is `"function"` and `registration instanceof Promise` is false, so code that branches on those checks or treats functions as callbacks is affected; Vitest's `.rejects` calls it instead of observing the rejection.
|
||||
|
||||
Registry-level `stateOf(session, key): unknown` is deprecated. It returns `undefined` whenever no plugin currently registers the key: its owner is not composed, has not reached registration, or has released its last registration. `SessionProjectionStateMap` is removed; host state types stay local to `ProjectionDefinition<K, S>` and are inferred from `stateSchema`. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles still fold Session events on demand. [Runtime compatibility](../../../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) details the deprecated calls.
|
||||
|
||||
`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state and provides `ctx.agents.turnBoundaryOf(session)`; `@deepseek-ai/dsh-agent-loop` no longer exports `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` removes `installModelSelectionProjection`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`.
|
||||
2. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle before publishing dependent operations. Remove `SessionProjectionStateMap` augmentations and pass the state type as the second `ProjectionDefinition<K, S>` argument. Keep client-value declarations in `SessionProjectionMap`.
|
||||
3. Replace `registry.stateOf(session, key)` with the owning service's method, such as `ctx.agents.turnBoundaryOf(session)`, or `handle.stateOf(session)` inside the owner; until then, handle its `undefined` result. Replace calls to the registration result with an awaited `handle.dispose()`; fiber disposal still releases registrations automatically.
|
||||
4. Where a native Promise is required, pass `Promise.resolve(registration)`, including `await expect(Promise.resolve(registration)).rejects.toThrow(...)` in Vitest. Replace `typeof` or `instanceof Promise` checks on the result with awaiting.
|
||||
5. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`.
|
||||
6. Typecheck custom plugins, then verify activation, a Session state read, a rejected registration, and owner disposal.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Session 投影注册异步解析为保留读取能力的句柄;可调用的注册结果与注册表级状态读取已弃用。"
|
||||
---
|
||||
# Session 投影注册句柄
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为 `Promise<ProjectionHandle<S>>`。定义同步安装;等待结果得到带 `stateOf(session)` 与 `dispose()` 的句柄,注册错误使结果拒绝。运行时返回值是可调用的 thenable,而非原生 Promise:调用它仍会释放注册,但该调用已弃用。`typeof registration` 为 `"function"`,`registration instanceof Promise` 为 false,因此按这些检查分支或把函数视为回调的代码会受影响;Vitest 的 `.rejects` 会调用它,而不是观察拒绝。
|
||||
|
||||
注册表级 `stateOf(session, key): unknown` 已弃用。当前没有任何插件注册该 key 时它返回 `undefined`:所属插件未组合、尚未执行到注册,或已释放最后一次注册。`SessionProjectionStateMap` 已移除;宿主状态类型保留在各自的 `ProjectionDefinition<K, S>` 中,并从 `stateSchema` 推导。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。[运行时兼容说明](../../../../packages/session/session-projection/README.zh.md#deprecated-plugin-compatibility)详述这些已弃用调用。
|
||||
|
||||
`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态,并提供 `ctx.agents.turnBoundaryOf(session)`;`@deepseek-ai/dsh-agent-loop` 不再导出 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 移除了 `installModelSelectionProjection`。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。
|
||||
2. 对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前保存句柄。删除 `SessionProjectionStateMap` 的声明合并,将状态类型作为 `ProjectionDefinition<K, S>` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。
|
||||
3. 将 `registry.stateOf(session, key)` 替换为所属服务的方法(例如 `ctx.agents.turnBoundaryOf(session)`),或所有者内部的 `handle.stateOf(session)`;替换之前须处理其 `undefined` 结果。将对注册结果的调用替换为等待 `handle.dispose()`;fiber 释放仍会自动释放注册。
|
||||
4. 需要原生 Promise 时传入 `Promise.resolve(registration)`,包括在 Vitest 中写 `await expect(Promise.resolve(registration)).rejects.toThrow(...)`。将对结果的 `typeof` 或 `instanceof Promise` 检查改为直接等待。
|
||||
5. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。
|
||||
6. 对自定义插件执行类型检查,再验证激活、Session 状态读取、注册被拒绝以及 owner 释放。
|
||||
@@ -9,8 +9,8 @@
|
||||
en: dc8c41fbf9869179
|
||||
zh: e45976a585cb9c86
|
||||
/web-ui-style-reference/component-rules:
|
||||
en: 3a35065f2be9de06
|
||||
zh: 41291bba391fb515
|
||||
en: 6715806a6f84d141
|
||||
zh: 94b7a6ede4919512
|
||||
/web-ui-style-reference/corner-radii-and-settings-cards:
|
||||
en: 118972f6c2bd6ce4
|
||||
zh: 85f8416b03e4dd8b
|
||||
|
||||
@@ -20,6 +20,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
|
||||
- Pair font sizes with line heights and use the theme typography variables when an existing role matches.
|
||||
- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors.
|
||||
- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches.
|
||||
- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten.
|
||||
- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls.
|
||||
- Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
|
||||
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。
|
||||
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
|
||||
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
|
||||
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。
|
||||
- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。
|
||||
- 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
|
||||
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 4e53164159b5ce41
|
||||
zh: 89582dc0294d0776
|
||||
/deepseek-ai-dsh-sdk-app/use-this-package:
|
||||
en: 63573087d1db9aff
|
||||
zh: 638612ac3f0da2c0
|
||||
en: b76bd7ea9880f529
|
||||
zh: e80c7b9fa331dd9b
|
||||
/deepseek-ai-dsh-sdk-app/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -24,17 +24,17 @@
|
||||
en: 4f88e647c6df4f42
|
||||
zh: 2bd3e4b0bdfe9412
|
||||
/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/what-the-model-sees:
|
||||
en: fc42eb5995eb2078
|
||||
zh: 4e47602e46ba5123
|
||||
en: 0cce2b88aae3a0e5
|
||||
zh: fcb31314096ef15e
|
||||
/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/token-effect:
|
||||
en: 4a0cdca06d358f53
|
||||
zh: d27080dcb68c661c
|
||||
en: 0d4a38b2448396b3
|
||||
zh: e63408a146bf0100
|
||||
/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/kv-cache-effect:
|
||||
en: 5b0e33295fb90e19
|
||||
zh: 4d48ca75bb677202
|
||||
en: 0503df0971e5f8d7
|
||||
zh: 18cae8c6e25cda04
|
||||
/deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work:
|
||||
en: 416e2793f9786480
|
||||
zh: 05cc4fd289d0185d
|
||||
en: 451497677de7e6b9
|
||||
zh: dcfdc671c38d54d4
|
||||
/deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -23,6 +23,8 @@ The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/RE
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The `sdk` profile includes native desktop tools and model context. Standard tool calling is the default; set `DSH_TOOLS_MODE=ptc` for programmatic tool calling. Both modes use the same plugins.
|
||||
|
||||
The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
|
||||
|
||||
| Config | Default | Behavior |
|
||||
@@ -44,20 +46,24 @@ The Python runtime exposes explicitly downloaded authoring resources and Office
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`.
|
||||
The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. The model also receives `present`, `ask_user_question`, schedule tools, shared file-reference guidance, and time context. Child agents cannot use schedule tools. The [`time-context`](../../context/time-context/README.md) row accepts a fallback time zone. Skills use the configured resources described above.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The base prompt sections and selected tool schemas determine token usage; this profile adds one coding-persona sentence.
|
||||
The profile adds a coding-persona sentence, the mounted tool definitions and guidance, and logged runtime-context and time messages.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Stable for a fixed profile, provider, model, and tool roster. Profile changes take effect on the next process because the shipped SDK profile uses startup-only patches.
|
||||
The static prefix is stable for a fixed profile, provider, model, and tool roster. Runtime values append to logged user context. Profile changes take effect on the next process because the shipped SDK profile uses startup-only patches.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **No graphical client** — file cards, native file opening, and browser state require an embedding client; the SDK starts no Web server.
|
||||
- **User time zone is unknown** — the SDK protocol does not carry a user time zone, so time context reports `Browser time zone for this request: unavailable.` The process or container time zone may differ from the user's. The `time-context.timeZone` setting only controls fallback timestamp formatting; it does not establish the user's time zone. Automatic detection and SDK time-zone input are deferred.
|
||||
- **Questions require an answerer** — configure `onUserQuestion` in TypeScript or `on_user_question` in Python, or supply a native `user-questions/request` handler; otherwise `ask_user_question` returns `NO_PROVIDER`.
|
||||
- **Schedules share the runtime lifetime** — delivery needs a running process and its persistent Session storage.
|
||||
- **A profile can omit the SDK server** — a custom profile selected by the TypeScript client must retain this bundle or another `dsh-sdk-jsonrpc-server` row; client initialization fails when no peer answers.
|
||||
- **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin.
|
||||
- **Configuration changes require restart** — the `sdk-app` bundle disables HMR in YAML so one stdio connection never observes a replacement server or Agent dependency.
|
||||
|
||||
@@ -23,6 +23,8 @@ kind: "package-bundle"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
`sdk` profile 包含原生桌面工具和模型上下文。默认使用标准工具调用;设置 `DSH_TOOLS_MODE=ptc` 可使用程序化工具调用。两种模式使用相同插件。
|
||||
|
||||
启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的会话标题;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
|
||||
|
||||
| 配置 | 默认值 | 行为 |
|
||||
@@ -44,20 +46,24 @@ Python 运行时向本 profile 提供显式下载的创作资源和 Office sidec
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。
|
||||
profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。模型还会收到 `present`、`ask_user_question`、定时任务工具、共用文件引用说明和时间上下文。子 agent 不能使用定时任务工具。[`time-context`](../../context/time-context/README.zh.md) 配置项接受回退时区。Skills 使用上述配置的资源。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
base 提示词段落与所选工具 schema 决定 token 用量;此 profile 额外添加一句 coding persona。
|
||||
此 profile 添加一句 coding persona、所挂载的工具定义与说明,以及记录在日志中的运行时上下文和时间消息。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
对固定 profile、提供方、模型与工具清单保持稳定。由于随附 SDK profile 使用仅启动时 patch,profile 变化会在下一个进程生效。
|
||||
对固定 profile、提供方、模型与工具清单,静态前缀保持稳定。运行时值追加到已记录的 user 上下文中。由于随附 SDK profile 使用仅启动时 patch,profile 变化会在下一个进程生效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **没有图形客户端**:文件卡片、本地文件打开和浏览器状态需要嵌入客户端;SDK 不启动 Web server。
|
||||
- **用户时区未知**:SDK 协议不携带用户时区,因此时间上下文显示 `Browser time zone for this request: unavailable.`。进程或容器的时区可能与用户时区不同。`time-context.timeZone` 配置只控制回退时间戳的显示格式,不能据此确定用户时区。自动检测和 SDK 时区传参暂缓支持。
|
||||
- **提问需要回答方**:在 TypeScript 中配置 `onUserQuestion`、在 Python 中配置 `on_user_question`,或提供原生 `user-questions/request` handler;否则 `ask_user_question` 返回 `NO_PROVIDER`。
|
||||
- **定时任务依赖运行时生命周期**:投递需要运行中的进程及其持久化 Session 存储。
|
||||
- **profile 可能省略 SDK server**:TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。
|
||||
- **用户插件可以破坏 stdout 纯净性**:profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。
|
||||
- **配置变化需要重启**:`sdk-app` 组合包在 YAML 中禁用 HMR,因此一个 stdio 连接不会观察到 server 或 agent 依赖被替换。
|
||||
|
||||
@@ -39,3 +39,40 @@
|
||||
assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')"
|
||||
cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
|
||||
|
||||
# Share native desktop tools and model context without starting a Web server.
|
||||
- id: tools
|
||||
config:
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
- insert:
|
||||
- id: connection
|
||||
name: '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
- id: file-upload
|
||||
name: '@deepseek-ai/dsh-client-file-upload'
|
||||
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
- id: schedule
|
||||
name: '@deepseek-ai/dsh-schedule'
|
||||
inject: [sdkSessionActivation]
|
||||
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
|
||||
- id: tool-schedule
|
||||
name: '@deepseek-ai/dsh-tool-schedule'
|
||||
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
- id: present
|
||||
name: '@deepseek-ai/dsh-tool-present'
|
||||
|
||||
- id: file-reference-local
|
||||
name: '@deepseek-ai/dsh-file-reference-local'
|
||||
|
||||
- id: deliverable-file-references
|
||||
name: '@deepseek-ai/dsh-deliverable-file-references'
|
||||
@@ -39,7 +39,17 @@
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"commander": "^15.0.0",
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-present": "workspace:*",
|
||||
"@deepseek-ai/dsh-file-reference-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -346,6 +346,9 @@
|
||||
- id: ui-cordis
|
||||
name: '@deepseek-ai/dsh-client-ui-cordis'
|
||||
|
||||
- id: deliverable-file-references
|
||||
name: '@deepseek-ai/dsh-deliverable-file-references'
|
||||
|
||||
# Turn tail: the changed-files card and delivery cards under each closing
|
||||
# assistant message. Remove this entry to turn the surface off; the tail
|
||||
# hole renders empty.
|
||||
|
||||
@@ -188,7 +188,8 @@
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*",
|
||||
"@deepseek-ai/dsh-harness-dev-context": "workspace:*"
|
||||
"@deepseek-ai/dsh-harness-dev-context": "workspace:*",
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
|
||||
@@ -70,6 +70,7 @@ Npm sections describe installation and development relationships; each build fac
|
||||
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
|
||||
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
|
||||
8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)).
|
||||
9. **Package images live under `src/client/assets/`.** Client code references one by relative `import` and the package's own CSS by relative `url()`; supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. The package declares `"./assets/*": "./lib/assets/*"` in `exports` and `lib/assets` in `files`; the build preset emits each referenced file under `lib/assets/`, and the browser loads it through the [client-modules asset route](modules/README.md).
|
||||
|
||||
## Build-time browser environment
|
||||
|
||||
@@ -143,7 +144,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json` plus `./assets/*` for [package images](#dependency-declaration), `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface ClientTransportHooks {
|
||||
* bundles load over HTTP.
|
||||
*/
|
||||
loadBundle?(url: string): Promise<void>
|
||||
/**
|
||||
* Package-asset resolver for the module system, present when the page cannot
|
||||
* request `plugins/<package>/assets/<path>` itself (the worker tunnel): it
|
||||
* maps each reference to a URL the page can load. Absent in the served web app.
|
||||
*/
|
||||
resolveAsset?(reference: string): string
|
||||
/**
|
||||
* The transport owner declares the page owns the Host outright: the Host
|
||||
* runs inside a worker this page spawned, so no other party can reach it and
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
en: 25f536f156ec0bcc
|
||||
zh: a522355b3e0edeae
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/declaring-a-client-plugin:
|
||||
en: d4a7f865f4593724
|
||||
zh: 4313eb8fe9cd5c98
|
||||
en: cc0eb2c35a7a0998
|
||||
zh: 0e1df23869410c7a
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/package-assets:
|
||||
en: 529af1eb8bf1acf2
|
||||
zh: 7d891a5dfa90c625
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
|
||||
en: 1383d68472b3c057
|
||||
zh: 94dd7d9abbdbccf3
|
||||
@@ -45,8 +48,8 @@
|
||||
en: 6d16b74acb333e3b
|
||||
zh: fcb3a20cc8c39e4a
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/incremental-composition:
|
||||
en: 41c38d220c99e49e
|
||||
zh: 4501a507fb1d97d5
|
||||
en: d2c42ee89ca84f7b
|
||||
zh: 70ee9dd5e6aa5327
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/boot-manifest-injection:
|
||||
en: 253d9f265cd83ed0
|
||||
zh: 9d0f8df84d9d3ac9
|
||||
@@ -54,8 +57,8 @@
|
||||
en: df2994f9ff8872c9
|
||||
zh: cd776526ed2b51e6
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/source-map:
|
||||
en: fabe9db8c44e2d90
|
||||
zh: 89ca94a8161f57ed
|
||||
en: d8b5407a0279a3f9
|
||||
zh: 27af3437fad78672
|
||||
/deepseek-ai-dsh-client-modules/further-exploration:
|
||||
en: 7e5b283efe4f1d16
|
||||
zh: 79472b64a0d759fe
|
||||
@@ -66,8 +69,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: de54f3467ef3b9be
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
|
||||
en: 6dbb155bc65c9d65
|
||||
zh: 368b553a56e46dfc
|
||||
en: 97127ddc35daf6d4
|
||||
zh: 1417b06046b01067
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -33,6 +33,11 @@ Use it when you compose or build a browser client plugin: the package turns a pa
|
||||
|
||||
A browser plugin package declares `dsh.client` in its `package.json` with `platform: 'web'`, exports a `./client` bundle, and lists any non-baseline module requests under `dsh.client.external`. The host half turns each declaration into a served bundle under `/plugins`, ordered so dynamic providers load before their consumers.
|
||||
|
||||
<a id="package-assets"></a>
|
||||
### Package assets
|
||||
|
||||
The same `/plugins` route serves the images a client package publishes through its `"./assets/*": "./lib/assets/*"` export. The build emits each referenced asset with its content revision, the first 12 hexadecimal digits of the SHA-1 digest of its bytes, inserted before the extension, and compiles the import, or the stylesheet `url()`, into a synchronous `require.asset("plugins/<package>/assets/<emitted path>")` call on the bundle's module loader, so one URL always names one content. Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`; a package built outside this repository must emit the same form. The call returns the document-relative reference unless the page's carrier supplies `resolveAsset`: the WebWorker preview, whose page cannot request the reference, maps it to a Blob URL of the bytes its Worker image holds. Any `/plugins/<package>/assets/` path is answered by the asset route alone: for an active client row, the Host percent-decodes the path once and resolves `<package>/assets/<path>` with Node's `require.resolve` from the package's own manifest, so the package's exports decide which files exist. A path that does not resolve, a resolved file whose extension is not `.svg`, `.png`, `.webp`, `.jpg`, or `.jpeg`, a malformed escape, and an inactive package answer 404; the query selects nothing. A served asset streams from disk on every request without caching and carries its media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag: "<requested path>"`, and `x-content-type-options: nosniff`; an SVG additionally carries `content-security-policy: script-src 'none'`, so an SVG opened directly runs no script. An `If-None-Match` equal to that tag answers 304 without opening the file, and `HEAD` answers without a body. `fetchBundle()` returns the same responses, with a streamed body that closes the file when cancelled.
|
||||
|
||||
### What the browser loads
|
||||
|
||||
The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
|
||||
@@ -73,7 +78,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
|
||||
|
||||
### Incremental composition
|
||||
|
||||
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
|
||||
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook).
|
||||
|
||||
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
|
||||
|
||||
@@ -92,6 +97,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
|
||||
| [`src/assets.ts`](src/assets.ts) | Package-asset media types, export, content revision, revisioned file name, and browser reference shared by the build preset, the asset route, and gates |
|
||||
| [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
|
||||
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
|
||||
| [`src/client/entries.ts`](src/client/entries.ts) | Page entry reconciliation, retries and code replacement |
|
||||
@@ -138,6 +144,8 @@ These limits define what the module system does not do. They are current package
|
||||
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
|
||||
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
|
||||
- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
|
||||
- **A manifest edit alone is not observed** — the Host reads a package manifest when its Loader entry starts or restarts; editing only `package.json` takes effect at the next entry restart or Host restart. The asset route resolves through Node's package configuration cache, so a `./assets/*` export edited in place takes effect only after boot HMR expires that package or the Host restarts.
|
||||
- **Desktop re-reads package assets on every display** — the Desktop forwarder rewrites every `/plugins/` cache header to `no-store`, so the immutable asset headers do not reach its browser cache.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -33,6 +33,11 @@ kind: "package-reference"
|
||||
|
||||
浏览器插件包在其 `package.json` 中以 `platform: 'web'` 声明 `dsh.client`,导出 `./client` bundle,并在 `dsh.client.external` 下列出任何基座之外的模块请求。宿主半侧把每份声明变成 `/plugins` 下提供的 bundle,并让动态提供方先于其消费方加载。
|
||||
|
||||
<a id="package-assets"></a>
|
||||
### 包资源
|
||||
|
||||
同一个 `/plugins` 路由也提供客户端包通过其 `"./assets/*": "./lib/assets/*"` 导出发布的图片。构建会产出每个被引用的资源,在扩展名之前插入其内容 revision(资源字节 SHA-1 摘要的前 12 位十六进制数字),并把 import 或样式表 `url()` 编译成对 bundle 模块加载器的同步调用 `require.asset("plugins/<package>/assets/<emitted path>")`,因此一个 URL 始终对应一份内容。每个路径段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;仓库外构建的包必须输出同样的形式。除非页面的载体提供了 `resolveAsset`,这个调用都返回文档相对引用;WebWorker 预览的页面无法请求该引用,它把引用映射为 Worker 镜像中对应字节的 Blob URL。任何 `/plugins/<package>/assets/` 路径都只由资源路由响应:对于活动的客户端 row,Host 对路径做一次百分号解码,并从该包自身的 manifest(元数据清单)出发,用 Node 的 `require.resolve` 解析 `<package>/assets/<path>`,因此由包的导出决定哪些文件存在。无法解析的路径、扩展名不是 `.svg`、`.png`、`.webp`、`.jpg` 或 `.jpeg` 的已解析文件、格式错误的转义以及未激活的包都返回 404;查询串不参与选择。提供的资源每次请求都从磁盘流式读取且不缓存,并携带其媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、`etag: "<requested path>"` 和 `x-content-type-options: nosniff`;SVG 还额外携带 `content-security-policy: script-src 'none'`,因此直接打开的 SVG 不会运行脚本。`If-None-Match` 等于该 etag 时返回 304 且不打开文件,`HEAD` 的响应不带 body。`fetchBundle()` 返回相同的响应,其流式 body 在取消时关闭文件。
|
||||
|
||||
### 浏览器加载什么
|
||||
|
||||
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
|
||||
@@ -74,7 +79,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
|
||||
|
||||
### 增量组合
|
||||
|
||||
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
|
||||
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。
|
||||
|
||||
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会通过异步文件 API 读取该脚本并缓存,因此首次异步读取时才物化文件的文件系统也能提供它;其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
|
||||
|
||||
@@ -93,6 +98,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 |
|
||||
| [`src/assets.ts`](src/assets.ts) | 包资源的媒体类型、导出、内容 revision、带 revision 的文件名与浏览器引用,由构建预设、资源路由和门禁共用 |
|
||||
| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 |
|
||||
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 |
|
||||
| [`src/client/entries.ts`](src/client/entries.ts) | 页面条目对账、重试与代码替换 |
|
||||
@@ -139,6 +145,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
|
||||
- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
|
||||
- **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
|
||||
- **仅编辑 manifest 不会被察觉**——Host 在包的 Loader 条目启动或重启时读取包 manifest;只编辑 `package.json` 要到下一次条目重启或 Host 重启时才生效。资源路由经 Node 的包配置缓存解析,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包的缓存失效、或 Host 重启后才生效。
|
||||
- **Desktop 每次显示都重新读取包资源**——Desktop 转发器把每个 `/plugins/` 缓存头改写为 `no-store`,因此不可变的资源响应头到达不了其浏览器缓存。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Package-asset format shared by the client build preset, the Host asset
|
||||
* route, and repository gates: the supported media types, the export that
|
||||
* publishes built assets, the content revision, and the document-relative
|
||||
* browser reference. Changing any value here changes a published URL or
|
||||
* package-manifest requirement, so the preset, the route, and the gates read
|
||||
* it from this one module.
|
||||
* @module @deepseek-ai/dsh-client-modules/src/assets
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Route segment between a package name and its asset path. */
|
||||
export const PACKAGE_ASSET_SEGMENT = 'assets'
|
||||
|
||||
/** Media type served for each supported asset extension, keyed by the lowercase extension with its dot. */
|
||||
export const PACKAGE_ASSET_MEDIA_TYPES: ReadonlyMap<string, string> = new Map([
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
])
|
||||
|
||||
/** Package export key that publishes built assets. */
|
||||
export const PACKAGE_ASSET_EXPORT = './assets/*'
|
||||
|
||||
/** The only accepted target of {@link PACKAGE_ASSET_EXPORT}. */
|
||||
export const PACKAGE_ASSET_EXPORT_TARGET = './lib/assets/*'
|
||||
|
||||
/** The `files` entry that publishes {@link PACKAGE_ASSET_EXPORT_TARGET}. */
|
||||
export const PACKAGE_ASSET_FILES_ENTRY = 'lib/assets'
|
||||
|
||||
/** Hex digits kept from the content digest; the same length as client artifact revisions. */
|
||||
const PACKAGE_ASSET_REVISION_LENGTH = 12
|
||||
|
||||
/**
|
||||
* Look up the media type of an asset path.
|
||||
* @param assetPath - file path or URL path; only its final extension is read, case-insensitively.
|
||||
* @returns the served media type, or undefined when the extension is unsupported.
|
||||
*/
|
||||
export function packageAssetMediaType(assetPath: string): string | undefined {
|
||||
const name = assetPath.slice(Math.max(assetPath.lastIndexOf('/'), assetPath.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot <= 0 ? undefined : PACKAGE_ASSET_MEDIA_TYPES.get(name.slice(dot).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content revision of one built asset. The build writes it into
|
||||
* the emitted file name, so a URL names exactly one content and a rebuilt
|
||||
* asset gets a new URL.
|
||||
* @param bytes - exact bytes written under `lib/assets/`.
|
||||
* @returns the first 12 hexadecimal digits of the SHA-1 digest.
|
||||
*/
|
||||
export function packageAssetRevision(bytes: Uint8Array): string {
|
||||
return createHash('sha1').update(bytes).digest('hex').slice(0, PACKAGE_ASSET_REVISION_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the emitted file of one asset by inserting its revision before the extension.
|
||||
* @param assetPath - `/`-separated path below `src/client/assets/`, such as `icons/[email protected]`.
|
||||
* @param rev - {@link packageAssetRevision} of the asset bytes.
|
||||
* @returns the path below `lib/assets/`, such as `icons/whale@2x-<rev>.png`.
|
||||
*/
|
||||
export function packageAssetRevisionedPath(assetPath: string, rev: string): string {
|
||||
const dot = assetPath.lastIndexOf('.')
|
||||
return dot > assetPath.lastIndexOf('/') + 1
|
||||
? `${assetPath.slice(0, dot)}-${rev}${assetPath.slice(dot)}`
|
||||
: `${assetPath}-${rev}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the browser reference to one built asset. The reference is relative
|
||||
* to the shell document, like every other app-owned route, so it resolves
|
||||
* under any document mount and from injected `<style>` text.
|
||||
* @param packageName - owning package name, emitted verbatim; a scoped name keeps its `/`.
|
||||
* @param emittedPath - {@link packageAssetRevisionedPath} result below `lib/assets/`; each segment is
|
||||
* percent-encoded, including `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`.
|
||||
* @returns `plugins/<packageName>/assets/<path>`.
|
||||
*/
|
||||
export function packageAssetReference(packageName: string, emittedPath: string): string {
|
||||
const path = emittedPath.split('/')
|
||||
.map(segment => encodeURIComponent(segment).replace(/[!'()*]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
.join('/')
|
||||
return `plugins/${packageName}/${PACKAGE_ASSET_SEGMENT}/${path}`
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export function createClientModuleSystem(
|
||||
registrationTarget: target,
|
||||
bootstrapModule,
|
||||
...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }),
|
||||
...(options.resolveAsset === undefined ? {} : { resolveAsset: options.resolveAsset }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,12 @@ export interface ClientBundleRequire {
|
||||
(specifier: string): unknown
|
||||
/** Load and resolve a package-local dynamic chunk asynchronously. */
|
||||
async(specifier: string): Promise<unknown>
|
||||
/**
|
||||
* Resolve a package-asset reference (`plugins/<package>/assets/<path>`) the
|
||||
* build compiled from an asset import or stylesheet `url()` to the URL the
|
||||
* page loads; the reference itself unless the carrier maps it.
|
||||
*/
|
||||
asset(reference: string): string
|
||||
}
|
||||
|
||||
/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
|
||||
@@ -375,6 +381,8 @@ export interface ClientModuleCreateOptions {
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
|
||||
resolveAsset?: (reference: string) => string
|
||||
}
|
||||
|
||||
/** The modules bundle after its factory has been materialized by the HTML bootstrap facade. */
|
||||
@@ -485,4 +493,6 @@ export interface ClientModuleSystemOptions {
|
||||
bootstrapModule: ClientBootstrapModule
|
||||
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
|
||||
resolveAsset?: (reference: string) => string
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
private readonly loadBundle: (url: string) => Promise<void>
|
||||
private readonly resolveAsset: (reference: string) => string
|
||||
/** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
|
||||
private readonly importErrors = new Map<string, Error>()
|
||||
/** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
|
||||
@@ -134,6 +135,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
})
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.loadBundle = options.loadBundle ?? defaultLoadBundle
|
||||
this.resolveAsset = options.resolveAsset ?? (reference => reference)
|
||||
|
||||
for (const row of options.manifest.modules) {
|
||||
this.graphRows.set(row.id, row)
|
||||
@@ -321,7 +323,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the synchronous module-table require and its asynchronous chunk operation. */
|
||||
/** Build the synchronous module-table require, its asynchronous chunk operation, and its asset resolver. */
|
||||
private makeRequire(ownerId: string, edges: Set<string>): ClientBundleRequire {
|
||||
const require = (spec: string): unknown => {
|
||||
edges.add(spec)
|
||||
@@ -335,6 +337,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
+ 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
|
||||
)
|
||||
}
|
||||
require.asset = (reference: string): string => this.resolveAsset(reference)
|
||||
require.async = async (spec: string): Promise<unknown> => {
|
||||
edges.add(spec)
|
||||
if (!spec.startsWith('./')) return await this.import(spec)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
|
||||
* combo scripts plus their source maps,
|
||||
* combo scripts plus their source maps, package-local chunks, and the
|
||||
* content-revisioned `./assets/*` exports of active client packages,
|
||||
* contributes the registration facade, application preloads, bootstrap scripts,
|
||||
* and graph to the webserver's index injection table, and provides the
|
||||
* `clientModules` service (the HMR node half's registration/notification
|
||||
@@ -14,26 +15,31 @@
|
||||
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
|
||||
* against the live loader entries. The activation pass seeds the same dirty
|
||||
* set with all current entries and flushes synchronously, so first scan and
|
||||
* steady state share one implementation. Package metadata (including the
|
||||
* negative "not a client package" verdict) is cached per Loader specifier and
|
||||
* owning-tree base URL until restart. The manifest package name identifies
|
||||
* the browser module; distinct active Loader sources for that package are a
|
||||
* composition error. Bundle content changes reach the graph only through
|
||||
* {@link ClientModuleRegistry.rebuilt}.
|
||||
* steady state share one implementation. Each reconciliation reads the
|
||||
* package manifest and the bundle metadata again, so an entry that restarts
|
||||
* after its package was reinstalled or moved publishes them as they are now;
|
||||
* an unchanged result publishes nothing and does not read the bundle. The
|
||||
* manifest package name identifies the browser module; distinct active Loader
|
||||
* sources for that package are a composition error. Bundle rebuilds outside
|
||||
* an entry restart reach the graph through {@link ClientModuleRegistry.rebuilt}.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { PACKAGE_ASSET_SEGMENT, packageAssetMediaType } from './assets.ts'
|
||||
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease } from './client/manifest.ts'
|
||||
|
||||
@@ -69,9 +75,11 @@ export interface ClientArtifactBaseline {
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
|
||||
/** Package metadata read from the manifest of one active Loader source. */
|
||||
interface PkgMeta extends WebBootRowFields {
|
||||
clientPath: string
|
||||
/** The package's own manifest; package-asset requests resolve against it as a self-reference. */
|
||||
manifestPath: string
|
||||
}
|
||||
|
||||
interface ResolvedPkgMeta {
|
||||
@@ -85,7 +93,7 @@ interface ClientPackageSource extends ResolvedPkgMeta {
|
||||
loaderName: string
|
||||
/** Resolution base of the config tree that owns the row. */
|
||||
baseUrl: string
|
||||
/** Stable cache and contribution key for this source. */
|
||||
/** Stable source identity and contribution key. */
|
||||
sourceKey: string
|
||||
}
|
||||
|
||||
@@ -225,6 +233,28 @@ function artifactRevision(baseline: ClientArtifactBaseline): string {
|
||||
/** Absolute route prefix serving every plugin resource. */
|
||||
const PLUGIN_ROUTE = '/plugins'
|
||||
|
||||
/**
|
||||
* `/plugins/<package>/assets/<path>`. A scoped package name spans two segments
|
||||
* (`@scope/name`); an unscoped name never starts with `@`, so `@scope/assets/…`
|
||||
* stays a chunk request of the package `@scope/assets`.
|
||||
*/
|
||||
const PACKAGE_ASSET_PATH = /^\/plugins\/(@[^/]+\/[^/]+|[^@/][^/]*)\/assets\/(.*)$/
|
||||
|
||||
/** Expose a Node read stream as a shell-carrier Response body; cancelling the body closes the stream. */
|
||||
function transforStreamBody(stream: Readable): ReadableStream<Uint8Array> {
|
||||
const chunks: AsyncIterator<Uint8Array> = stream[Symbol.asyncIterator]()
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const next = await chunks.next()
|
||||
if (next.done === true) controller.close()
|
||||
else controller.enqueue(next.value)
|
||||
},
|
||||
async cancel() {
|
||||
await chunks.return?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Combo query addressing one ordered plugin-file list. */
|
||||
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
@@ -595,8 +625,8 @@ window.__ModuleLoader__={
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental `dsh.client` scan + wire composition
|
||||
* + bundle route + index injection rows. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* + bundle and package-asset route + index injection rows. Construction runs
|
||||
* the activation scan synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
* boot activation audit reports it).
|
||||
*/
|
||||
@@ -610,9 +640,6 @@ export class ClientModuleRegistry extends Service {
|
||||
private readonly release: WebBootRelease
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
private readonly sources = new Map<string, ClientPackageSource>()
|
||||
// Resolution is entry-local: the same specifier can resolve differently in
|
||||
// separate config trees. Negative verdicts remain stable until restart.
|
||||
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
private readonly graphListeners = new Set<() => void>()
|
||||
private readonly dirty = new Set<string>()
|
||||
@@ -690,16 +717,20 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
async fetchBundle(request: Request): Promise<Response> {
|
||||
const resource = await this.bundleResource(request.method, request.url)
|
||||
const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
|
||||
const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined)
|
||||
const body = resource.body === undefined ? null
|
||||
: resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body)
|
||||
return new Response(body, {
|
||||
status: resource.status,
|
||||
...(resource.headers === undefined ? {} : { headers: resource.headers }),
|
||||
@@ -721,7 +752,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -751,7 +782,8 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
@@ -842,14 +874,10 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
|
||||
const sourceKey = this.sourceKey(loaderName, baseUrl)
|
||||
const cached = this.pkgMeta.get(sourceKey)
|
||||
if (cached !== undefined) return cached
|
||||
const located = this.locatePkgJson(loaderName, baseUrl)
|
||||
if (located === undefined) {
|
||||
// Not a resolvable package root: loader builtins (cordis:include) and
|
||||
// subpath entries (…/gateway) land here — permanently not a client row.
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
// subpath entries (…/gateway) land here — not a client row.
|
||||
return null
|
||||
}
|
||||
const { packageName, path: pkgPath } = located
|
||||
@@ -859,23 +887,19 @@ export class ClientModuleRegistry extends Service {
|
||||
packageName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
return null
|
||||
}
|
||||
if (decl === undefined || decl.platform !== 'web') return null
|
||||
const clientRel = clientExportOf(packageName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
manifestPath: pkgPath,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
|
||||
external: decl.external ?? [],
|
||||
immediately: decl.immediately === true,
|
||||
}
|
||||
const resolved = { packageName, meta }
|
||||
this.pkgMeta.set(sourceKey, resolved)
|
||||
return resolved
|
||||
return { packageName, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -920,7 +944,7 @@ export class ClientModuleRegistry extends Service {
|
||||
: internal.resolveSync(loaderName, baseUrl, {}).url
|
||||
} catch {
|
||||
// The Loader cannot resolve the name: its row cannot have imported, so
|
||||
// the name is permanently not a client row.
|
||||
// it is not a client row.
|
||||
return undefined
|
||||
}
|
||||
return this.nearestPackage(moduleUrl, expectedPackageName)
|
||||
@@ -968,20 +992,16 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the activation-time bundle snapshot.
|
||||
* Run one filesystem read of a declared client bundle.
|
||||
* @param pkgName - package that declares the client bundle.
|
||||
* @param clientPath - absolute path of the built client artifact.
|
||||
* @returns the immutable bytes plus the pre-read filesystem baseline.
|
||||
* @param read - the stat or read of `clientPath`.
|
||||
* @returns the read's result.
|
||||
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
|
||||
*/
|
||||
private initialBundleSnapshot(pkgName: string, clientPath: string): {
|
||||
bundle: Buffer
|
||||
baseline: ClientArtifactBaseline
|
||||
} {
|
||||
private readClientBundle<T>(pkgName: string, clientPath: string, read: () => T): T {
|
||||
try {
|
||||
const baseline = this.captureArtifactBaseline(clientPath)
|
||||
const bundle = readFileSync(clientPath)
|
||||
return { bundle, baseline }
|
||||
return read()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
throw new MissingClientBundleError(pkgName, clientPath, error)
|
||||
@@ -1054,17 +1074,22 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
const source = sources[0]
|
||||
if (source === undefined) return this.table.delete(packageName)
|
||||
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
|
||||
const { clientPath } = source.meta
|
||||
// Startup and HMR share revisions so unchanged artifacts survive a server restart.
|
||||
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
|
||||
const rev = artifactRevision(snapshot.baseline)
|
||||
const baseline = this.readClientBundle(packageName, clientPath, () => this.captureArtifactBaseline(clientPath))
|
||||
const entry = graphRow(packageName, artifactRevision(baseline), source.meta)
|
||||
const current = this.table.get(packageName)
|
||||
// A restart that finds the same manifest and bundle metadata leaves the published graph as it is, unread.
|
||||
const unchanged = current?.sourceKey === source.sourceKey
|
||||
&& isDeepStrictEqual(current.meta, source.meta) && isDeepStrictEqual(current.entry, entry)
|
||||
if (unchanged) return false
|
||||
this.table.set(packageName, {
|
||||
entry: graphRow(packageName, rev, source.meta),
|
||||
entry,
|
||||
loaderName: source.loaderName,
|
||||
sourceKey: source.sourceKey,
|
||||
meta: source.meta,
|
||||
bundle: snapshot.bundle,
|
||||
baseline: snapshot.baseline,
|
||||
bundle: this.readClientBundle(packageName, clientPath, () => readFileSync(clientPath)),
|
||||
baseline,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -1150,13 +1175,71 @@ export class ClientModuleRegistry extends Service {
|
||||
return response
|
||||
}
|
||||
|
||||
private async bundleResource(method: string | undefined, url: string): Promise<{
|
||||
/**
|
||||
* Answer one package-asset request. Node's `require.resolve` applies the
|
||||
* package's `./assets/*` export as a self-reference, so unexported,
|
||||
* `.`/`..`, missing, and directory paths answer 404. The build writes each
|
||||
* asset's content revision into its file name, so a served path names one
|
||||
* content: the body streams from disk on every request, and the path is the
|
||||
* entity tag.
|
||||
* @param method - request method.
|
||||
* @param record - the active row of the addressed package, or undefined when it is not active.
|
||||
* @param assetPath - percent-encoded path below the package's `assets/` route segment.
|
||||
* @param ifNoneMatch - request `If-None-Match` field.
|
||||
* @returns the route answer.
|
||||
*/
|
||||
private assetResponse(
|
||||
method: 'GET' | 'HEAD',
|
||||
record: WebPluginRecord | undefined,
|
||||
assetPath: string,
|
||||
ifNoneMatch: string | undefined,
|
||||
): {
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer
|
||||
body?: Readable
|
||||
} {
|
||||
if (record === undefined || assetPath === '') return { status: 404 }
|
||||
let file: string
|
||||
try {
|
||||
file = createRequire(record.meta.manifestPath)
|
||||
.resolve(`${record.entry.id}/${PACKAGE_ASSET_SEGMENT}/${decodeURIComponent(assetPath)}`)
|
||||
} catch {
|
||||
// Unresolvable or malformed paths name no asset.
|
||||
return { status: 404 }
|
||||
}
|
||||
// The resolved file decides the type: Node reads `#` and `?` in the subpath as URL syntax.
|
||||
const mediaType = packageAssetMediaType(file)
|
||||
if (mediaType === undefined) return { status: 404 }
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': mediaType,
|
||||
'cache-control': IMMUTABLE_CACHE,
|
||||
etag: `"${assetPath}"`,
|
||||
'x-content-type-options': 'nosniff',
|
||||
// A directly opened SVG document runs no script.
|
||||
...(mediaType === 'image/svg+xml' ? { 'content-security-policy': "script-src 'none'" } : {}),
|
||||
}
|
||||
if (ifNoneMatch === headers.etag) return { status: 304, headers }
|
||||
return { status: 200, headers, ...(method === 'HEAD' ? {} : { body: createReadStream(file) }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one `/plugins` request. A `/plugins/<package>/assets/` path is
|
||||
* answered by the package-asset route alone; any other path is an advertised
|
||||
* combo or its map, a prior startup generation, or a current-revision
|
||||
* package-local chunk, in that order, and otherwise 404.
|
||||
*/
|
||||
private async bundleResource(method: string | undefined, url: string, ifNoneMatch: string | undefined): Promise<{
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer | Readable
|
||||
}> {
|
||||
if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
|
||||
const requestUrl = new URL(url, 'http://x')
|
||||
const asset = PACKAGE_ASSET_PATH.exec(requestUrl.pathname)
|
||||
if (asset !== null) {
|
||||
const [, packageName = '', assetPath = ''] = asset
|
||||
return this.assetResponse(method, this.table.get(packageName), assetPath, ifNoneMatch)
|
||||
}
|
||||
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
|
||||
const response = this.responses.get(resourceUrl)
|
||||
?? this.previousBatchResponses.get(resourceUrl)
|
||||
@@ -1175,9 +1258,18 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/')
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match'])
|
||||
res.writeHead(response.status, response.headers)
|
||||
res.end(response.body)
|
||||
if (!(response.body instanceof Readable)) {
|
||||
res.end(response.body)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await pipeline(response.body, res)
|
||||
} catch (error) {
|
||||
// The browser cancelled the request, as leaving an onboarding step does; pipeline already closed the file.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Package-asset format: media types, content revisions, revisioned file names, and document-relative references. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
PACKAGE_ASSET_SEGMENT,
|
||||
packageAssetMediaType,
|
||||
packageAssetReference,
|
||||
packageAssetRevision,
|
||||
packageAssetRevisionedPath,
|
||||
} from '../src/assets.ts'
|
||||
|
||||
describe('packageAssetMediaType', () => {
|
||||
it.each([
|
||||
['icon.svg', 'image/svg+xml'],
|
||||
['photo.png', 'image/png'],
|
||||
['nested/photo.webp', 'image/webp'],
|
||||
['photo.jpg', 'image/jpeg'],
|
||||
['photo.jpeg', 'image/jpeg'],
|
||||
['PHOTO.PNG', 'image/png'],
|
||||
['Icon.SvG', 'image/svg+xml'],
|
||||
['archive.txt.png', 'image/png'],
|
||||
['nested\\photo.png', 'image/png'],
|
||||
['C:\\assets\\photo.webp', 'image/webp'],
|
||||
])('serves %s as %s', (assetPath, mediaType) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBe(mediaType)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['photo', 'no extension'],
|
||||
['.png', 'a dotfile'],
|
||||
['nested/.png', 'a nested dotfile'],
|
||||
['photo.png/readme', 'an extension on a parent segment only'],
|
||||
['photo.png\\readme', 'an extension before a backslash separator'],
|
||||
['photo.gif', 'an unsupported extension'],
|
||||
['photo.png.txt', 'an unsupported final extension'],
|
||||
['photo.', 'an empty extension'],
|
||||
])('serves nothing for %s (%s)', (assetPath) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevision', () => {
|
||||
it('keeps the first 12 hexadecimal digits of the SHA-1 content digest', () => {
|
||||
expect(packageAssetRevision(new TextEncoder().encode('abc'))).toBe('a9993e364706')
|
||||
expect(packageAssetRevision(new Uint8Array())).toBe('da39a3ee5e6b')
|
||||
})
|
||||
|
||||
it('changes with the asset bytes', () => {
|
||||
expect(packageAssetRevision(Uint8Array.of(1))).not.toBe(packageAssetRevision(Uint8Array.of(2)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevisionedPath', () => {
|
||||
it.each([
|
||||
['photo.png', 'photo-a9993e364706.png'],
|
||||
['icons/[email protected]', 'icons/[email protected]'],
|
||||
['icons/badge.zh.svg', 'icons/badge.zh-a9993e364706.svg'],
|
||||
['icons.v2/photo', 'icons.v2/photo-a9993e364706'],
|
||||
['icons/.png', 'icons/.png-a9993e364706'],
|
||||
])('inserts the revision before the extension of %s', (assetPath, emitted) => {
|
||||
expect(packageAssetRevisionedPath(assetPath, 'a9993e364706')).toBe(emitted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetReference', () => {
|
||||
it('keeps a scoped package name verbatim and encodes each path segment', () => {
|
||||
expect(packageAssetReference('@scope/pkg', 'icons/a b#?%-a9993e364706.svg'))
|
||||
.toBe('plugins/@scope/pkg/assets/icons/a%20b%23%3F%25-a9993e364706.svg')
|
||||
// Characters encodeURIComponent keeps would end an unquoted CSS url().
|
||||
expect(packageAssetReference('plain', "whale(2x)!'*-a9993e364706.png"))
|
||||
.toBe('plugins/plain/assets/whale%282x%29%21%27%2A-a9993e364706.png')
|
||||
})
|
||||
|
||||
it('addresses the document-relative form of the plugin asset route without a query', () => {
|
||||
const reference = packageAssetReference('plain', 'photo-a9993e364706.png')
|
||||
expect(reference).toBe('plugins/plain/assets/photo-a9993e364706.png')
|
||||
expect(reference.startsWith(`plugins/plain/${PACKAGE_ASSET_SEGMENT}/`)).toBe(true)
|
||||
})
|
||||
|
||||
it('pairs the asset export with its only accepted target and published files entry', () => {
|
||||
expect(PACKAGE_ASSET_EXPORT).toBe('./assets/*')
|
||||
expect(PACKAGE_ASSET_EXPORT_TARGET).toBe(`./${PACKAGE_ASSET_FILES_ENTRY}/*`)
|
||||
})
|
||||
})
|
||||
@@ -81,6 +81,7 @@ function bench(
|
||||
transportFailures?: Record<string, number>
|
||||
/** URLs whose script loads but registers only the listed ids (a runtime throw after those registrations). */
|
||||
registerOnly?: Record<string, string[]>
|
||||
resolveAsset?: (reference: string) => string
|
||||
} = {},
|
||||
): Bench {
|
||||
const fetched: string[] = []
|
||||
@@ -144,6 +145,7 @@ function bench(
|
||||
},
|
||||
staticModules: opts.seed ?? {},
|
||||
...(opts.defaultTransport === true ? {} : { loadBundle }),
|
||||
...(opts.resolveAsset === undefined ? {} : { resolveAsset: opts.resolveAsset }),
|
||||
})
|
||||
return { loader, target, fetched, gates }
|
||||
}
|
||||
@@ -249,6 +251,16 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves a package-asset reference through the carrier, or returns it unchanged without one', async () => {
|
||||
const reference = 'plugins/a/assets/mark-0123456789ab.svg'
|
||||
const factory: Factory = req => ({ url: req.asset(reference) })
|
||||
const served = bench([row('a')], { a: factory })
|
||||
expect(await served.loader.import('a', '', {})).toEqual({ url: reference })
|
||||
|
||||
const mapped = bench([row('a')], { a: factory }, { resolveAsset: asset => `blob:${asset}` })
|
||||
expect(await mapped.loader.import('a', '', {})).toEqual({ url: `blob:${reference}` })
|
||||
})
|
||||
|
||||
it('loads a package-local dynamic chunk only when its factory requests it', async () => {
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ load: () => req.async('./client.terminal.js') }),
|
||||
|
||||
@@ -224,7 +224,7 @@ async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await (await route).handler({ method, url } as IncomingMessage, response)
|
||||
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
}
|
||||
|
||||
@@ -967,6 +967,20 @@ describe('client bundle activation', () => {
|
||||
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('serves a chunk of a package named @scope/assets instead of treating it as an asset request', async () => {
|
||||
const packageName = '@fixture/assets'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
writeFileSync(join(dirname(clientPath), 'client.extra.js'), 'module.exports = { marker: "extra chunk" }\n')
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const row = service.graph().entries[0]!
|
||||
|
||||
const chunk = await routeRequest(route, chunkReference(packageName, 'client.extra.js', row.rev))
|
||||
expect(chunk.status).toBe(200)
|
||||
expect(chunk.body.toString('utf8')).toContain('extra chunk')
|
||||
})
|
||||
|
||||
it('reads a package-local chunk and its map body only through the asynchronous filesystem face', async () => {
|
||||
const packageName = '@fixture/async-chunk'
|
||||
const clientPath = writePackage(packageName)
|
||||
@@ -1245,3 +1259,72 @@ describe('module graph order', () => {
|
||||
.toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package manifest refresh', () => {
|
||||
/** Rewrite a fixture package's manifest; its client bundle is left as it is. */
|
||||
const writeManifest = (packageName: string, manifest: Record<string, unknown>): void => {
|
||||
writeFileSync(join(root!, 'node_modules', ...packageName.split('/'), 'package.json'), JSON.stringify({ name: packageName, ...manifest }))
|
||||
}
|
||||
const clientManifest = (client: Record<string, unknown>, clientExport = './lib/client.js'): Record<string, unknown> => ({
|
||||
exports: { './client': clientExport, './package.json': './package.json' },
|
||||
dsh: { client: { platform: 'web', ...client } },
|
||||
})
|
||||
|
||||
it('publishes a reinstalled declaration when its Loader entry restarts, and nothing for an unchanged restart', async () => {
|
||||
const packageName = '@fixture/refresh-declaration'
|
||||
writeBuiltPackage(packageName, { external: ['react'] })
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
const before = service.graph().entries[0]!
|
||||
const changed = vi.fn()
|
||||
service.onGraphChanged(changed)
|
||||
const warning = vi.spyOn(context.logger, 'warn')
|
||||
// An unchanged restart compares bundle metadata and never reads the bundle.
|
||||
syncRefused.add(service.clientPath(packageName)!)
|
||||
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
expect(warning).not.toHaveBeenCalled()
|
||||
syncRefused.delete(service.clientPath(packageName)!)
|
||||
|
||||
writeManifest(packageName, clientManifest({ external: ['scheduler'], inject: ['@fixture/slots'] }))
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(service.graph().entries[0]).toMatchObject({ rev: before.rev, external: ['scheduler'], inject: ['@fixture/slots'] })
|
||||
})
|
||||
|
||||
it('reads a moved client export as a new bundle generation when its Loader entry restarts', async () => {
|
||||
const packageName = '@fixture/refresh-moved'
|
||||
writeBuiltPackage(packageName, {})
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
const before = service.graph().entries[0]!
|
||||
const moved = join(root!, 'node_modules', '@fixture', 'refresh-moved', 'dist', 'client.js')
|
||||
mkdirSync(dirname(moved), { recursive: true })
|
||||
writeFileSync(moved, 'module.exports = { moved: true }\n')
|
||||
writeManifest(packageName, clientManifest({}, './dist/client.js'))
|
||||
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.clientPath(packageName)).toBe(moved)
|
||||
expect(service.graph().entries[0]!.rev).not.toBe(before.rev)
|
||||
})
|
||||
|
||||
it('adds or removes the row when a restarted package gains or loses its client declaration', async () => {
|
||||
const packageName = '@fixture/refresh-membership'
|
||||
writeBuiltPackage(packageName, {})
|
||||
writeManifest(packageName, { exports: { './package.json': './package.json' } })
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
expect(service.graph().entries).toEqual([])
|
||||
|
||||
writeManifest(packageName, clientManifest({}))
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
||||
|
||||
writeManifest(packageName, { exports: { './package.json': './package.json' } })
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.graph().entries).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
/** Host package-asset route: exported `./assets/*` files streamed under their revisioned names. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { request } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
|
||||
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { packageAssetReference } from '../src/assets.ts'
|
||||
import { ClientModuleRegistry } from '../src/index.ts'
|
||||
|
||||
/** File read streams the route opens, in order, to observe hashing and body streaming. */
|
||||
const opened = vi.hoisted(() => [] as { path: string; stream: ReadStream }[])
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
createReadStream: (path: Parameters<typeof actual.createReadStream>[0], options?: Parameters<typeof actual.createReadStream>[1]) => {
|
||||
const stream = actual.createReadStream(path, options)
|
||||
opened.push({ path: String(path), stream })
|
||||
return stream
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const ASSETS_ID = '@fixture/assets'
|
||||
const NO_EXPORT_ID = '@fixture/no-export'
|
||||
const INACTIVE_ID = '@fixture/inactive'
|
||||
/** Response headers the asset route owns. */
|
||||
const ASSET_HEADERS = ['content-type', 'cache-control', 'etag', 'x-content-type-options', 'content-security-policy'] as const
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\n')
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1])
|
||||
const WEBP = Buffer.from('RIFF....WEBP')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
opened.length = 0
|
||||
})
|
||||
|
||||
/** Own one fresh temporary directory per test. */
|
||||
function fixtureRoot(): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-assets-')))
|
||||
return root
|
||||
}
|
||||
|
||||
/** Write one file, creating its parent directories. */
|
||||
function writeFile(path: string, contents: string | Buffer): string {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, contents)
|
||||
return path
|
||||
}
|
||||
|
||||
/** Create a built client package below the fixture's `node_modules`; returns its `lib/assets` directory. */
|
||||
function writeClientPackage(packageName: string, exports: Record<string, string | null>): string {
|
||||
const pkgRoot = join(fixtureRoot(), 'node_modules', ...packageName.split('/'))
|
||||
writeFile(join(pkgRoot, 'package.json'), JSON.stringify({
|
||||
name: packageName,
|
||||
exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json', ...exports },
|
||||
dsh: { client: { platform: 'web' } },
|
||||
}))
|
||||
writeFile(join(pkgRoot, 'lib', 'index.js'), 'export default {}\n')
|
||||
writeFile(join(pkgRoot, 'lib', 'client.js'), 'module.exports = {}\n')
|
||||
writeFile(join(pkgRoot, 'secret.png'), PNG)
|
||||
return join(pkgRoot, 'lib', 'assets')
|
||||
}
|
||||
|
||||
/** Create the active, inactive, and export-less packages; returns the active package's `lib/assets`. */
|
||||
function writeFixture(): string {
|
||||
const assets = writeClientPackage(ASSETS_ID, { './assets/private/*': null, './assets/*': './lib/assets/*' })
|
||||
writeFile(join(assets, 'icon.svg'), SVG)
|
||||
writeFile(join(assets, 'photo.png'), PNG)
|
||||
writeFile(join(assets, 'nested', 'a b.webp'), WEBP)
|
||||
// `*` is covered by the reference unit test; Windows cannot create it on disk.
|
||||
writeFile(join(assets, "whale(2x)!'.png"), PNG)
|
||||
writeFile(join(assets, 'notes.txt'), 'text\n')
|
||||
writeFile(join(assets, 'folder.png', 'inner.png'), PNG)
|
||||
writeFile(join(assets, 'private', 'hidden.png'), PNG)
|
||||
// `a#b.png` resolves here because Node reads `#` in the subpath as a fragment.
|
||||
writeFile(join(assets, 'a'), PNG)
|
||||
writeFile(join(writeClientPackage(NO_EXPORT_ID, {}), 'photo.png'), PNG)
|
||||
writeFile(join(writeClientPackage(INACTIVE_ID, { './assets/*': './lib/assets/*' }), 'photo.png'), PNG)
|
||||
return assets
|
||||
}
|
||||
|
||||
/** One route answer reduced to the parts the asset route owns. */
|
||||
interface Answer {
|
||||
status: number
|
||||
headers: Partial<Record<typeof ASSET_HEADERS[number], string>>
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
function assetHeaders(read: (name: string) => string | string[] | null | undefined): Answer['headers'] {
|
||||
const headers: Answer['headers'] = {}
|
||||
for (const name of ASSET_HEADERS) {
|
||||
const value = read(name)
|
||||
if (typeof value === 'string') headers[name] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
interface AssetRequestInit {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/** Composed Host: the modules service and a requester through both carriers. */
|
||||
interface Host {
|
||||
service: ClientModuleRegistry
|
||||
/** Port of the real Web server. */
|
||||
port: number
|
||||
/** Warnings any plugin of the composed Host logged. */
|
||||
warnings: unknown[][]
|
||||
/** Send one raw request target through the Web route and the shell carrier and require identical answers. */
|
||||
both: (path: string, init?: AssetRequestInit) => Promise<Answer>
|
||||
}
|
||||
|
||||
/** Compose the real Web server and the modules service over a Loader double listing the active rows. */
|
||||
async function composeHost(): Promise<Host> {
|
||||
const internal = ModuleLoader.fromInternal()
|
||||
if (internal === undefined) throw new Error('package-asset tests require the Node module loader internals')
|
||||
const context = new Context()
|
||||
contexts.push(context)
|
||||
const warnings: unknown[][] = []
|
||||
context.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
const baseUrl = pathToFileURL(fixtureRoot()).href + '/'
|
||||
context.baseUrl = baseUrl
|
||||
context.provide('loader', {
|
||||
internal,
|
||||
*entries() {
|
||||
for (const name of [NO_EXPORT_ID, ASSETS_ID]) {
|
||||
yield { options: { name }, fiber: {}, disabled: false, parent: { tree: { ctx: { baseUrl } } } }
|
||||
}
|
||||
},
|
||||
})
|
||||
await context.plugin(WebServer, { host: '127.0.0.1', port: 0 })
|
||||
await context.plugin(ClientModuleRegistry)
|
||||
const service = context.get('clientModules')!
|
||||
const port = context.webServer.port!
|
||||
const http = (path: string, init: AssetRequestInit = {}): Promise<Answer> => new Promise((resolve, reject) => {
|
||||
const outgoing = request({ host: '127.0.0.1', port, path, method: init.method ?? 'GET', headers: init.headers, agent: false }, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
res.on('error', reject)
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode!, headers: assetHeaders(name => res.headers[name]), body: Buffer.concat(chunks) })
|
||||
})
|
||||
})
|
||||
outgoing.on('error', reject)
|
||||
outgoing.end()
|
||||
})
|
||||
const both = async (path: string, init?: AssetRequestInit): Promise<Answer> => {
|
||||
const answer = await http(path, init)
|
||||
const response = await service.fetchBundle(new Request(`http://localhost${path}`, init))
|
||||
expect({
|
||||
status: response.status,
|
||||
headers: assetHeaders(name => response.headers.get(name)),
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
}).toEqual(answer)
|
||||
return answer
|
||||
}
|
||||
// The Web route registers once the injected webServer is ready.
|
||||
await expect.poll(async () => (await http(assetUrl(ASSETS_ID, 'photo.png'))).status).toBe(200)
|
||||
return { service, port, warnings, both }
|
||||
}
|
||||
|
||||
/** Absolute route the browser requests for the document-relative build reference to an emitted file. */
|
||||
function assetUrl(packageName: string, emittedPath: string): string {
|
||||
return `/${packageAssetReference(packageName, emittedPath)}`
|
||||
}
|
||||
|
||||
describe('package asset route', () => {
|
||||
it('streams exported assets with immutable typed headers and the path as entity tag', async () => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
|
||||
expect(await both(assetUrl(ASSETS_ID, 'icon.svg'))).toEqual({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml',
|
||||
'cache-control': IMMUTABLE,
|
||||
etag: '"icon.svg"',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "script-src 'none'",
|
||||
},
|
||||
body: SVG,
|
||||
})
|
||||
expect(await both(assetUrl(ASSETS_ID, 'photo.png'))).toEqual({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': IMMUTABLE,
|
||||
etag: '"photo.png"',
|
||||
'x-content-type-options': 'nosniff',
|
||||
},
|
||||
body: PNG,
|
||||
})
|
||||
const nested = await both(assetUrl(ASSETS_ID, 'nested/a b.webp'))
|
||||
expect([nested.status, nested.headers.etag, nested.body]).toEqual([200, '"nested/a%20b.webp"', WEBP])
|
||||
// The reference encodes the characters an unquoted CSS url() rejects; the route decodes them back.
|
||||
const special = await both(assetUrl(ASSETS_ID, "whale(2x)!'.png"))
|
||||
expect([special.status, special.headers.etag, special.body]).toEqual([200, '"whale%282x%29%21%27.png"', PNG])
|
||||
// The revision lives in the emitted name; a query does not select content.
|
||||
expect((await both(`${assetUrl(ASSETS_ID, 'photo.png')}?rev=anything`)).body).toEqual(PNG)
|
||||
})
|
||||
|
||||
it('answers HEAD with the same headers and no body, and rejects other methods', async () => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
const url = assetUrl(ASSETS_ID, 'icon.svg')
|
||||
const get = await both(url)
|
||||
|
||||
expect(await both(url, { method: 'HEAD' })).toEqual({ ...get, body: Buffer.alloc(0) })
|
||||
expect((await both(url, { method: 'POST' })).status).toBe(405)
|
||||
})
|
||||
|
||||
it('answers 304 without opening the file only when If-None-Match equals the entity tag', async () => {
|
||||
const assets = writeFixture()
|
||||
const { both } = await composeHost()
|
||||
const url = assetUrl(ASSETS_ID, 'icon.svg')
|
||||
const get = await both(url)
|
||||
const opens = opened.filter(entry => entry.path === join(assets, 'icon.svg')).length
|
||||
|
||||
expect(await both(url, { headers: { 'if-none-match': '"icon.svg"' } }))
|
||||
.toEqual({ status: 304, headers: get.headers, body: Buffer.alloc(0) })
|
||||
expect(opened.filter(entry => entry.path === join(assets, 'icon.svg'))).toHaveLength(opens)
|
||||
expect(await both(url, { headers: { 'if-none-match': '"photo.png"' } })).toEqual(get)
|
||||
})
|
||||
|
||||
it('streams an asset spanning several read chunks intact through both carriers', async () => {
|
||||
const assets = writeFixture()
|
||||
// Several default 64 KiB read-stream chunks; small enough for the deep body comparison.
|
||||
const large = Buffer.alloc(160 * 1024, 7)
|
||||
writeFile(join(assets, 'large.png'), large)
|
||||
const { both } = await composeHost()
|
||||
|
||||
const answer = await both(assetUrl(ASSETS_ID, 'large.png'))
|
||||
expect(answer.status).toBe(200)
|
||||
expect(answer.body.equals(large)).toBe(true)
|
||||
})
|
||||
|
||||
it('logs nothing when the browser cancels a streamed asset response', async () => {
|
||||
const assets = writeFixture()
|
||||
const file = writeFile(join(assets, 'large.png'), Buffer.alloc(8 * 1024 * 1024, 7))
|
||||
const { port, warnings } = await composeHost()
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const outgoing = request({ host: '127.0.0.1', port, path: assetUrl(ASSETS_ID, 'large.png'), agent: false }, (res) => {
|
||||
res.once('data', () => {
|
||||
outgoing.destroy()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
// The destroyed request reports its own abort; the cancellation is the point of the test.
|
||||
outgoing.on('error', () => undefined)
|
||||
outgoing.on('close', () => { resolve() })
|
||||
outgoing.end()
|
||||
})
|
||||
const stream = opened.findLast(entry => entry.path === file)?.stream
|
||||
if (stream === undefined) throw new Error('the asset route opened no file stream')
|
||||
// events.once would reject on the premature-close error the cancelled pipeline reports; only the close matters here.
|
||||
if (!stream.closed) await new Promise<void>((resolve) => { stream.once('close', () => { resolve() }) })
|
||||
// pipeline settles its rejection in the turn after the file stream closes; the route logs nothing then.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('closes the file stream when a shell-carrier body is cancelled', async () => {
|
||||
const assets = writeFixture()
|
||||
const { service } = await composeHost()
|
||||
const response = await service.fetchBundle(new Request(`http://localhost${assetUrl(ASSETS_ID, 'photo.png')}`))
|
||||
const body = opened.findLast(entry => entry.path === join(assets, 'photo.png'))?.stream
|
||||
|
||||
await response.body?.cancel()
|
||||
expect(body?.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
const base = `/plugins/${ASSETS_ID}/assets`
|
||||
it.each([
|
||||
['an excluded export subpath', `${base}/private/hidden.png`],
|
||||
['a package without the asset export', `/plugins/${NO_EXPORT_ID}/assets/photo.png`],
|
||||
['a missing file', `${base}/missing.png`],
|
||||
['`..` leaving the asset prefix', `${base}/../../secret.png`],
|
||||
['`%2e%2e` leaving the asset prefix', `${base}/%2e%2e/%2e%2e/secret.png`],
|
||||
['an encoded `..` path', `${base}/..%2f..%2fsecret.png`],
|
||||
['an empty asset path', `${base}/`],
|
||||
['a malformed percent-escape', `${base}/photo%zz.png`],
|
||||
['a decoded `%`', `${base}/100%25.png`],
|
||||
['a decoded `#` that Node reads as a fragment', `${base}/a%23b.png`],
|
||||
['an unsupported extension', `${base}/notes.txt`],
|
||||
['a directory', `${base}/folder.png`],
|
||||
['an inactive package', `/plugins/${INACTIVE_ID}/assets/photo.png`],
|
||||
])('answers 404 for %s', async (_case, url) => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
|
||||
expect(await both(url)).toEqual({ status: 404, headers: {}, body: Buffer.alloc(0) })
|
||||
})
|
||||
})
|
||||
@@ -6,18 +6,34 @@
|
||||
* lightningcss inside the bundle: `x.module.css` yields its hashed class map
|
||||
* and injects a tagged style at factory execution, while `x.css?inline`
|
||||
* exports compiled text for a plugin-owned lifecycle effect. The virtual
|
||||
* loaders register each real stylesheet as a watch dependency. SVG, PNG, and
|
||||
* WebP imports become watched base64 data URLs inside the bundle.
|
||||
* loaders register each real stylesheet as a watch dependency. Images under
|
||||
* `src/client/assets/`, imported from Client code or referenced by relative
|
||||
* `url()` in the package's own stylesheets, are emitted under `lib/assets/`
|
||||
* with their content revision in the file name and become document-relative
|
||||
* URLs served by the
|
||||
* client-modules asset route; such a package exports `./assets/*` as
|
||||
* `./lib/assets/*`, and its Node Client face rejects asset imports.
|
||||
* Non-experimental client outputs reject experimental module and stylesheet
|
||||
* inputs, including origins recorded by chained source maps.
|
||||
*/
|
||||
import { readFile, stat, utimes } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile, readdir, rm, rmdir, stat, utimes } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire, isBuiltin } from 'node:module'
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { basename, dirname, isAbsolute, join, posix, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { transform, type CustomAtRules, type Url, type Visitor } from 'lightningcss'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
PACKAGE_ASSET_MEDIA_TYPES,
|
||||
packageAssetMediaType,
|
||||
packageAssetReference,
|
||||
packageAssetRevision,
|
||||
packageAssetRevisionedPath,
|
||||
} from './modules/src/assets.ts'
|
||||
import { optionalStringArray } from './modules/src/client/manifest.ts'
|
||||
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
|
||||
import { clientBuildEnvironmentDefines, clientBuildValueDefines } from '../../scripts/client-build-environment.ts'
|
||||
@@ -35,10 +51,10 @@ const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
const INLINE_CSS_QUERY = '?inline'
|
||||
|
||||
/** Image imports become base64 data URLs embedded in the plugin's own bundle. */
|
||||
const IMAGE_MEDIA_TYPES = new Map([['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.webp', 'image/webp']])
|
||||
|
||||
/** Emit one plugin-owned style injector and an optional CSS Modules export. */
|
||||
/**
|
||||
* Emit one plugin-owned style injector and an optional CSS Modules export.
|
||||
* @param css - JavaScript expression yielding the stylesheet text; see {@link stylesheetSource}.
|
||||
*/
|
||||
function styleInjectionModule(
|
||||
id: string,
|
||||
fileId: string,
|
||||
@@ -46,7 +62,7 @@ function styleInjectionModule(
|
||||
classMap?: Readonly<Record<string, string>>,
|
||||
): string {
|
||||
const source = [
|
||||
`const css = ${JSON.stringify(css)};`,
|
||||
`const css = ${css};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
@@ -106,7 +122,9 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
* original source content, into the standalone plugin map.
|
||||
* @param packageDir - the package's own directory, normally the config's
|
||||
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
|
||||
* the __ModuleLoader__.load handoff and onto the injected style tags.
|
||||
* the __ModuleLoader__.load handoff and onto the injected style tags, and the
|
||||
* owner segment of its asset URLs; its `src/client/assets/` holds the only
|
||||
* images the browser bundle may reference.
|
||||
* @param libEntry - node-half entries.
|
||||
* @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner.
|
||||
* @returns ENV-selected tsdown config for the current build face.
|
||||
@@ -122,7 +140,7 @@ export function clientBundle(
|
||||
return ({ env }) => {
|
||||
const face = buildFace(env?.DSH_BUILD_FACE)
|
||||
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
|
||||
const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true)
|
||||
const client = clientConfig(manifest, packageDir, clientEntry, options.clientBanner, options.dualClient === true)
|
||||
const node = [lib, ...(options.companions ?? [])]
|
||||
const clientFaces = options.dualClient === true
|
||||
? [nodeClientConfig(manifest, clientEntry), client]
|
||||
@@ -277,6 +295,11 @@ interface AssetEmitter {
|
||||
}): string
|
||||
}
|
||||
|
||||
/** The slice of the rolldown plugin context that emits and watches one package asset. */
|
||||
interface AssetContext extends AssetEmitter {
|
||||
addWatchFile(id: string): void
|
||||
}
|
||||
|
||||
function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
|
||||
const emitted = new Set<string>()
|
||||
const isolation = clientInputIsolation(id)
|
||||
@@ -358,6 +381,8 @@ interface PackageManifest {
|
||||
readonly dependencies?: Record<string, string>
|
||||
readonly peerDependencies?: Record<string, string>
|
||||
readonly optionalDependencies?: Record<string, string>
|
||||
/** Package exports; the browser face emits assets only while `./assets/*` publishes `lib/assets`. */
|
||||
readonly exports?: Readonly<Record<string, unknown>>
|
||||
readonly dsh?: { readonly client?: { readonly external?: unknown } }
|
||||
}
|
||||
|
||||
@@ -478,6 +503,255 @@ function asyncChunkRequirePlugin(): TsdownPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/** Emitted asset directory below the browser config's `lib` outDir, published as {@link PACKAGE_ASSET_FILES_ENTRY}. */
|
||||
const ASSET_OUTPUT_DIR = posix.relative('lib', PACKAGE_ASSET_FILES_ENTRY)
|
||||
|
||||
/** Characters the asset route rejects in a decoded path segment, because Node resolves export subpaths as URL text. */
|
||||
const UNSERVABLE_ASSET_SEGMENT = /[%#?\\\0]/
|
||||
|
||||
/** A `url()` value with a scheme, such as `data:` or `https:`, which names no package file. */
|
||||
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
|
||||
|
||||
/**
|
||||
* Free identifier an asset reference compiles to before rendering; the CommonJS
|
||||
* render turns each call into `require.asset(...)`, the module loader's
|
||||
* synchronous asset resolver, which returns the reference unchanged unless its
|
||||
* carrier maps it. The fixed hexadecimal suffix keeps plugin strings that happen
|
||||
* to contain the shorter prefix out of that rewrite, and keeps the source maps
|
||||
* that carry the loaded module text byte-stable across builds.
|
||||
*/
|
||||
const PACKAGE_ASSET_CALL = '__dshPackageAsset_5f8e2c1a9b7d'
|
||||
|
||||
/** Compile one asset reference into the resolver call. */
|
||||
function packageAssetCall(reference: string): string {
|
||||
return `${PACKAGE_ASSET_CALL}(${JSON.stringify(reference)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile stylesheet text into a JavaScript expression that passes each package-asset
|
||||
* reference the `url()` rewrite inserted through the resolver call. The rewrite writes
|
||||
* a placeholder of letters, digits, and underscores, which lightningcss never escapes,
|
||||
* with a per-stylesheet random part that stylesheet text cannot contain by chance.
|
||||
* A placeholder may appear several times or not at all, as when minification copies
|
||||
* or drops a declaration.
|
||||
* @param css - stylesheet text after the rewrite.
|
||||
* @param references - reference of each placeholder the rewrite inserted into this stylesheet.
|
||||
* @returns a string literal, or a concatenation of literals and resolver calls.
|
||||
*/
|
||||
function stylesheetSource(css: string, references: ReadonlyMap<string, string>): string {
|
||||
if (references.size === 0) return JSON.stringify(css)
|
||||
const parts: string[] = []
|
||||
let last = 0
|
||||
for (const match of css.matchAll(new RegExp([...references.keys()].join('|'), 'gu'))) {
|
||||
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(references.get(match[0]) as string))
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
parts.push(JSON.stringify(css.slice(last)))
|
||||
return parts.join(' + ')
|
||||
}
|
||||
|
||||
/** One stylesheet's `url()` rewrite: the lightningcss options and the references they insert while transforming. */
|
||||
interface StylesheetAssets {
|
||||
/** lightningcss options; empty when the stylesheet lies outside the package's own `src`. */
|
||||
readonly options: { readonly visitor?: Visitor<CustomAtRules> }
|
||||
/** Reference of each placeholder inserted during the transform that uses {@link options}. */
|
||||
readonly references: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** Browser-face package assets: the import plugin and the stylesheet `url()` rewrite share one per-build emission record. */
|
||||
interface PackageAssets {
|
||||
/** `dsh-package-asset`: resolves asset imports, emits referenced files, and prunes stale `lib/assets` files after writing. */
|
||||
readonly plugin: TsdownPlugin
|
||||
/**
|
||||
* Select the lightningcss options that rewrite one stylesheet's relative `url()` values.
|
||||
* @param context - plugin context of the stylesheet loader, which emits each referenced asset.
|
||||
* @param stylesheet - physical stylesheet path.
|
||||
* @returns a `url()` visitor when the stylesheet lies inside the package's own `src`, otherwise no options,
|
||||
* and the references the visitor inserts.
|
||||
* @throws {Error} from the visitor when a relative `url()` does not name a publishable package asset.
|
||||
*/
|
||||
urlRewrite(context: AssetContext, stylesheet: string): StylesheetAssets
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the package-asset rules of one dynamic browser bundle. A referenced
|
||||
* file must lie under the package's own `src/client/assets/`, have a supported
|
||||
* extension, and be published by the `./assets/*` export; it is emitted once
|
||||
* per build under `lib/assets/` at its relative path with the content revision
|
||||
* inserted before the extension, and the reference compiles to the module loader's
|
||||
* synchronous resolver call on its document-relative URL.
|
||||
* A one-shot build deletes files under `lib/assets/` that it did not emit; a
|
||||
* watch build keeps them for pages still running an earlier generation.
|
||||
* @param manifest - the package's own manifest, naming the URL owner and declaring the asset export.
|
||||
* @param packageDir - the package's own directory.
|
||||
* @returns the plugin and stylesheet visitor sharing the per-build emission record.
|
||||
*/
|
||||
function packageAssets(manifest: PackageManifest, packageDir: string): PackageAssets {
|
||||
const name = manifest.name
|
||||
const sourceDir = resolvePath(packageDir, 'src')
|
||||
const assetsDir = resolvePath(sourceDir, 'client', 'assets')
|
||||
/** Reference of every asset emitted in the current build, keyed by its real path. */
|
||||
const references = new Map<string, string>()
|
||||
|
||||
const reference = (context: AssetContext, file: string): string => {
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = realpathSync(file)
|
||||
} catch (error) {
|
||||
throw new Error(`tsdown: ${name}: cannot resolve asset ${file}`, { cause: error })
|
||||
}
|
||||
const emitted = references.get(realPath)
|
||||
if (emitted !== undefined) return emitted
|
||||
const below = pathBelow(realDirectory(assetsDir), realPath)
|
||||
if (below === undefined) throw new Error(`tsdown: ${name}: ${file} is outside src/client/assets; move the asset there`)
|
||||
const assetPath = below.split(sep).join('/')
|
||||
if (assetPath.split('/').some(segment => UNSERVABLE_ASSET_SEGMENT.test(segment) || segment.toLowerCase() === 'node_modules')) {
|
||||
throw new Error(`tsdown: ${name}: ${file} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
|
||||
}
|
||||
const extension = posix.extname(assetPath)
|
||||
if (extension !== extension.toLowerCase()) {
|
||||
throw new Error(`tsdown: ${name}: ${file} has an uppercase extension; asset imports are typed by lowercase extensions, so rename it`)
|
||||
}
|
||||
if (manifest.exports?.[PACKAGE_ASSET_EXPORT] !== PACKAGE_ASSET_EXPORT_TARGET) {
|
||||
throw new Error(
|
||||
`tsdown: ${name}: ${file} needs package.json exports ${JSON.stringify({ [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET })} `
|
||||
+ `and ${JSON.stringify(PACKAGE_ASSET_FILES_ENTRY)} in files`,
|
||||
)
|
||||
}
|
||||
if (packageAssetMediaType(assetPath) === undefined) {
|
||||
throw new Error(`tsdown: ${name}: ${file} is not a supported asset; supported extensions: ${[...PACKAGE_ASSET_MEDIA_TYPES.keys()].join(', ')}`)
|
||||
}
|
||||
const bytes = readFileSync(realPath)
|
||||
const emittedPath = packageAssetRevisionedPath(assetPath, packageAssetRevision(bytes))
|
||||
context.emitFile({ type: 'asset', fileName: `${ASSET_OUTPUT_DIR}/${emittedPath}`, source: bytes, originalFileName: realPath })
|
||||
context.addWatchFile(realPath)
|
||||
const url = packageAssetReference(name, emittedPath)
|
||||
references.set(realPath, url)
|
||||
return url
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: {
|
||||
name: 'dsh-package-asset',
|
||||
buildStart() { references.clear() },
|
||||
resolveId(source, importer) {
|
||||
if (importer === undefined || packageAssetMediaType(source) === undefined) return null
|
||||
return sourceAssetPath(source, importer)
|
||||
},
|
||||
load(id) {
|
||||
if (!isAbsolute(id) || packageAssetMediaType(id) === undefined) return null
|
||||
return `export default ${packageAssetCall(reference(this, id))};`
|
||||
},
|
||||
renderChunk(code, _chunk, outputOptions) {
|
||||
if (outputOptions.format !== 'cjs' || !code.includes(PACKAGE_ASSET_CALL)) return null
|
||||
const transformed = new Rolldown.RolldownMagicString(code)
|
||||
for (const match of code.matchAll(new RegExp(`\\b${PACKAGE_ASSET_CALL}\\(`, 'gu'))) {
|
||||
transformed.overwrite(match.index, match.index + match[0].length, 'require.asset(')
|
||||
}
|
||||
return transformed
|
||||
},
|
||||
async writeBundle(outputOptions, bundle) {
|
||||
// A watch rebuild keeps earlier files: the Host still serves the previous
|
||||
// bundle generation, and pages running it request those asset names.
|
||||
if (this.meta.watchMode || outputOptions.dir === undefined) return
|
||||
const outDir = outputOptions.dir
|
||||
const assets = resolvePath(outDir, ASSET_OUTPUT_DIR)
|
||||
if (!existsSync(assets)) return
|
||||
await pruneDirectory(assets, file => relative(outDir, file).split(sep).join('/') in bundle)
|
||||
},
|
||||
},
|
||||
urlRewrite(context, stylesheet) {
|
||||
const inserted = new Map<string, string>()
|
||||
const nonce = randomBytes(6).toString('hex')
|
||||
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return { options: {}, references: inserted }
|
||||
const base = dirname(stylesheet)
|
||||
const rewrite = (url: Url): Url | undefined => {
|
||||
// Empty values, fragments, root-relative paths, schemes such as `data:`, and placeholders name no package file.
|
||||
if (url.url === '' || url.url.startsWith('#') || url.url.startsWith('/') || URL_SCHEME.test(url.url)) return undefined
|
||||
if (inserted.has(url.url)) return undefined
|
||||
if (/[#?]/u.test(url.url)) {
|
||||
throw new Error(`tsdown: ${name}: ${stylesheet} references ${JSON.stringify(url.url)}; a package asset url() takes no fragment or query`)
|
||||
}
|
||||
const placeholder = `__dshPackageAsset_${nonce}_${String(inserted.size)}__`
|
||||
inserted.set(placeholder, reference(context, resolvePath(base, url.url)))
|
||||
return { ...url, url: placeholder }
|
||||
}
|
||||
return {
|
||||
options: {
|
||||
visitor: {
|
||||
Url: url => rewrite(url),
|
||||
// lightningcss does not visit the urls inside image-set(), so its options are rewritten here.
|
||||
Image(image) {
|
||||
if (image.type !== 'image-set') return undefined
|
||||
const options = image.value.options.map((option) => {
|
||||
const url = option.image.type === 'url' ? rewrite(option.image.value) : undefined
|
||||
return url === undefined ? option : { ...option, image: { type: 'url' as const, value: url } }
|
||||
})
|
||||
return { ...image, value: { ...image.value, options } }
|
||||
},
|
||||
},
|
||||
},
|
||||
references: inserted,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Real path of an existing directory, or undefined when it does not exist. */
|
||||
function realDirectory(directory: string): string | undefined {
|
||||
return existsSync(directory) ? realpathSync(directory) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a real path strictly inside a real directory.
|
||||
* @param root - real directory path, or undefined when the directory does not exist.
|
||||
* @param file - real file path.
|
||||
* @returns the platform-separated path of `file` below `root`, or undefined when it is not inside.
|
||||
*/
|
||||
function pathBelow(root: string | undefined, file: string): string | undefined {
|
||||
if (root === undefined) return undefined
|
||||
const path = relative(root, file)
|
||||
return path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) ? undefined : path
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every file a predicate rejects below a directory, then every directory left empty.
|
||||
* @param directory - directory to prune; removed itself when nothing remains.
|
||||
* @param keep - whether a file path survives.
|
||||
* @returns whether the directory was removed.
|
||||
*/
|
||||
async function pruneDirectory(directory: string, keep: (file: string) => boolean): Promise<boolean> {
|
||||
let remaining = 0
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!(await pruneDirectory(path, keep))) remaining++
|
||||
} else if (keep(path)) {
|
||||
remaining++
|
||||
} else {
|
||||
await rm(path)
|
||||
}
|
||||
}
|
||||
if (remaining > 0) return false
|
||||
await rmdir(directory)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject asset imports in a Node Client face, which has no asset route.
|
||||
* @param id - package name, used in diagnostics.
|
||||
* @returns a plugin whose resolveId throws for every supported asset extension.
|
||||
*/
|
||||
function browserOnlyAssets(id: string): TsdownPlugin {
|
||||
return {
|
||||
name: 'dsh-package-asset-browser-only',
|
||||
resolveId(source) {
|
||||
if (packageAssetMediaType(source) === undefined) return null
|
||||
throw new Error(`tsdown: ${id}: asset imports are browser-only (${source})`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */
|
||||
function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig {
|
||||
const isolation = clientInputIsolation(manifest.name)
|
||||
@@ -486,7 +760,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
|
||||
entry: { 'client.node': entry },
|
||||
sourcemap: true,
|
||||
inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } },
|
||||
plugins: [tscSourceMapPlugin(), isolation.plugin],
|
||||
plugins: [browserOnlyAssets(manifest.name), tscSourceMapPlugin(), isolation.plugin],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.node.js',
|
||||
chunkFileNames: 'client.node.[name].js',
|
||||
@@ -498,6 +772,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
|
||||
|
||||
function clientConfig(
|
||||
manifest: PackageManifest,
|
||||
packageDir: string,
|
||||
entry: string,
|
||||
clientBanner?: (fileName: string) => string | undefined,
|
||||
dualClient = false,
|
||||
@@ -506,6 +781,7 @@ function clientConfig(
|
||||
let requested: ReadonlySet<string> | undefined
|
||||
const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier)
|
||||
const isolation = clientInputIsolation(id)
|
||||
const assets = packageAssets(manifest, packageDir)
|
||||
return {
|
||||
name: `${id}/client`,
|
||||
entry: { client: entry },
|
||||
@@ -589,17 +865,19 @@ function clientConfig(
|
||||
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: '[hash]_[local]' },
|
||||
minify: true,
|
||||
...stylesheetAssets.options,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
const exportEntries = Object.entries(cssExports ?? {})
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
for (const [local, exp] of exportEntries) classMap[local] = exp.name
|
||||
return styleInjectionModule(id, fileId, code.toString(), classMap)
|
||||
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references), classMap)
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-text-inline',
|
||||
@@ -614,8 +892,9 @@ function clientConfig(
|
||||
const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return `export default ${JSON.stringify(code.toString())};`
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
|
||||
return `export default ${stylesheetSource(code.toString(), stylesheetAssets.references)};`
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-global-inline',
|
||||
@@ -629,23 +908,11 @@ function clientConfig(
|
||||
const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return styleInjectionModule(id, fileId, code.toString())
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
|
||||
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references))
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-image-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (importer === undefined || !IMAGE_MEDIA_TYPES.has(extname(source))) return null
|
||||
return sourceAssetPath(source, importer)
|
||||
},
|
||||
async load(fileId: string) {
|
||||
const mediaType = IMAGE_MEDIA_TYPES.get(extname(fileId))
|
||||
if (mediaType === undefined) return null
|
||||
this.addWatchFile(fileId)
|
||||
const data = await readFile(fileId)
|
||||
return `export default ${JSON.stringify(`data:${mediaType};base64,${data.toString('base64')}`)};`
|
||||
},
|
||||
}],
|
||||
}, assets.plugin],
|
||||
outputOptions: {
|
||||
entryFileNames: dualClient ? 'client.browser.js' : 'client.js',
|
||||
// The imported source basename becomes the published chunk name; package
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: b0d806604bc17006
|
||||
zh: 18dabe5dc2d0d419
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: 408664597c1480ed
|
||||
zh: 287a9b981337f288
|
||||
en: a14c74ed7de0ef9c
|
||||
zh: d5e618b2ef809150
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 284737900cb75668
|
||||
zh: 12e0844082ff79ce
|
||||
|
||||
@@ -107,11 +107,11 @@ The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belon
|
||||
|
||||
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration.
|
||||
|
||||
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. CSS variables do not belong in the Group Definition.
|
||||
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. The earlier-history control directly precedes the flow anchor; their sibling relationship supplies the leading gap. CSS variables do not belong in the Group Definition.
|
||||
|
||||
Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it.
|
||||
|
||||
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The build embeds the image in the plugin stylesheet; React does not pass image data through DOM styles.
|
||||
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles.
|
||||
|
||||
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
|
||||
|
||||
|
||||
@@ -107,11 +107,11 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
|
||||
|
||||
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。
|
||||
|
||||
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。CSS 变量不属于 Group Definition。
|
||||
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。更早历史控件紧邻流锚点之前,两者的兄弟关系决定首行间距。CSS 变量不属于 Group Definition。
|
||||
|
||||
运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。
|
||||
|
||||
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。构建将图片嵌入插件样式表;React 不通过 DOM 样式传递图片数据。
|
||||
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。
|
||||
|
||||
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./assets/*": "./lib/assets/*",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
@@ -101,6 +102,7 @@
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -64,10 +64,11 @@
|
||||
|
||||
/* The flow slot's display:contents anchor preserves layout but not selector ancestry.
|
||||
`hidden="until-found"` retains a searchable zero-height box; hidden and empty
|
||||
Seats do not contribute spacing. */
|
||||
Seats do not contribute spacing. The older-history control precedes the anchor;
|
||||
an ancestor :has() here also invalidates the history on unrelated descendant edits. */
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
|
||||
~ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
|
||||
.column:has(> .older) > :global([data-slot="conversation.chat.flow"])
|
||||
.column > .older + :global([data-slot="conversation.chat.flow"])
|
||||
> :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 6px);
|
||||
}
|
||||
@@ -326,7 +327,7 @@
|
||||
.runningWhaleAnimated {
|
||||
display: block;
|
||||
background: currentColor;
|
||||
mask: url('./[email protected]') center / 100% 100% no-repeat;
|
||||
mask: url('../assets/[email protected]') center / 100% 100% no-repeat;
|
||||
mask-mode: alpha;
|
||||
/* Keep animated-mask repaints on an icon-sized composited layer. */
|
||||
will-change: transform;
|
||||
|
||||
@@ -167,12 +167,19 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null
|
||||
}
|
||||
|
||||
function runBatch(current: InputFoldDriver): void {
|
||||
/** @returns whether a batch started; without a closing member the caller settles held rows itself. */
|
||||
function runBatch(current: InputFoldDriver): boolean {
|
||||
// Every member continues from the size it has now, so the driver's measured layout and its change
|
||||
// amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a
|
||||
// row that finished closing, and a revealing box may have grown part of the way on its own transition.
|
||||
// amounts describe the same boxes: a closing box may have moved on its own transition, and a revealing
|
||||
// box may have grown part of the way on its own transition.
|
||||
for (const box of [...boxes.values()]) {
|
||||
if (!batchMember(box)) continue
|
||||
// A row that finished closing is `hidden="until-found"`, which skips its contents without removing
|
||||
// their boxes: a box under it still reports its own size but contributes nothing to the flow.
|
||||
if (box.element.parentElement?.closest('[hidden]') != null) {
|
||||
box.finish()
|
||||
continue
|
||||
}
|
||||
const height = box.element.offsetHeight
|
||||
const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0
|
||||
if (!box.closing) {
|
||||
@@ -187,7 +194,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
const owned = [...boxes.values()]
|
||||
const members = owned.filter(batchMember)
|
||||
.sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
||||
if (!members.some(box => box.closing)) return
|
||||
if (!members.some(box => box.closing)) return false
|
||||
batch = true
|
||||
cancelled = false
|
||||
const travelling = current.begin(members.map(box => box.closing ? box : {
|
||||
@@ -284,6 +291,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
}
|
||||
if (reduced) tick(0)
|
||||
else frameRequest = requestAnimationFrame(tick)
|
||||
return true
|
||||
}
|
||||
|
||||
function foldActive(): boolean { return folding > 0 }
|
||||
@@ -299,10 +307,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
// Rows that change during a batch are held until it settles, which flushes again.
|
||||
if (batch) return
|
||||
const current = [...boxes.values()].some(box => box.closing) ? driver() : null
|
||||
if (current !== null) {
|
||||
runBatch(current)
|
||||
return
|
||||
}
|
||||
if (current !== null && runBatch(current)) return
|
||||
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
// Without a batch, a held closing row finishes at once and a held revealing row starts its own transition.
|
||||
for (const box of [...boxes.values()]) {
|
||||
|
||||
@@ -781,11 +781,13 @@ export class ChatViewport {
|
||||
|
||||
/**
|
||||
* Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader
|
||||
* movement, the test fold batches use for a tail landing, and the content overflows, so following scrolls
|
||||
* absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero
|
||||
* duration there. A resize calls this after its own follow scroll, before paint, so a slide that starts in
|
||||
* that frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and
|
||||
* follow-intent commits call it with the latest measurement. The attribute changes only with the pin.
|
||||
* movement, the test fold batches use for a tail landing, and the content overflows with no fold room
|
||||
* below it, so following scrolls absorb output growth and the status keeps its on-screen position. Fold
|
||||
* room, like the empty space below content that fits, takes growth before any scroll, so the status moves
|
||||
* there. The first-output slide runs at zero duration while pinned. A resize calls this after its own
|
||||
* follow scroll, before paint, so a slide that starts in that frame finishes before it is drawn; scroll and
|
||||
* scrollend call it after delivering the sample, and follow-intent commits call it with the latest
|
||||
* measurement. The attribute changes only with the pin.
|
||||
* @param overflowing - a fresh overflow measurement; omitted, the latest one is reused.
|
||||
*/
|
||||
refreshTailPin(overflowing = this.overflowing): void {
|
||||
@@ -793,7 +795,7 @@ export class ChatViewport {
|
||||
const elements = this.elements
|
||||
if (elements === null) return
|
||||
const reader = this.reader
|
||||
const pinned = overflowing && reader !== null && reader.follow.active && !reader.pending()
|
||||
const pinned = overflowing && this.spacerHeight() === 0 && reader !== null && reader.follow.active && !reader.pending()
|
||||
if (pinned === this.tailPinned) return
|
||||
this.tailPinned = pinned
|
||||
if (pinned) elements.column.dataset.chatTailPinned = ''
|
||||
|
||||
@@ -655,3 +655,73 @@ it('pins the tail only while sampled follow intent holds over overflowing conten
|
||||
viewport.detach()
|
||||
expect(pinned()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the tail unpinned while fold room takes growth, then pins once the room is used', () => {
|
||||
let notifyResize = () => {}
|
||||
class Observer implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', Observer)
|
||||
const scroller = document.createElement('div')
|
||||
const column = document.createElement('div')
|
||||
const spacer = document.createElement('div')
|
||||
spacer.dataset.chatTurnSpacer = ''
|
||||
scroller.append(column, spacer)
|
||||
document.body.append(scroller)
|
||||
let content = 900
|
||||
let top = 0
|
||||
const room = () => Number.parseFloat(spacer.style.height) || 0
|
||||
const floor = () => Math.max(0, content + room() - 300)
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { configurable: true, get: () => 300 },
|
||||
scrollHeight: { configurable: true, get: () => Math.max(300, content + room()) },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => Math.min(top, floor()),
|
||||
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
|
||||
},
|
||||
})
|
||||
const viewport = new ChatViewport()
|
||||
viewport.attach(scroller, column)
|
||||
const follow = new ScrollFollow(true, 4)
|
||||
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
|
||||
initialized: true, followingTail: true, activeTurn: 1,
|
||||
}, vi.fn(), follow)
|
||||
const unbind = reading.bindFolds()
|
||||
viewport.connect({
|
||||
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
|
||||
resize: () => { reading.onResize() },
|
||||
})
|
||||
onTestFinished(() => {
|
||||
unbind()
|
||||
reading.dispose()
|
||||
viewport.detach()
|
||||
scroller.remove()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
|
||||
|
||||
notifyResize()
|
||||
expect(scroller.scrollTop).toBe(600)
|
||||
expect(pinned()).toBe(true)
|
||||
// A fold landing leaves room below the content and commits the tail landing.
|
||||
spacer.style.height = '200px'
|
||||
scroller.scrollTop = 800
|
||||
reading.followTail()
|
||||
expect(pinned()).toBe(false)
|
||||
// Growth fills the room without scrolling, so the status moves on screen and keeps its slide.
|
||||
content = 940
|
||||
notifyResize()
|
||||
expect(room()).toBe(160)
|
||||
expect(scroller.scrollTop).toBe(800)
|
||||
expect(pinned()).toBe(false)
|
||||
// Once growth has used the room, following scrolls absorb it again.
|
||||
content = 1_100
|
||||
notifyResize()
|
||||
expect(room()).toBe(0)
|
||||
expect(scroller.scrollTop).toBe(800)
|
||||
expect(pinned()).toBe(true)
|
||||
})
|
||||
@@ -430,6 +430,46 @@ describe('flow motion', () => {
|
||||
expectCleared(opening)
|
||||
})
|
||||
|
||||
it('finishes a held row whose ancestor finished closing instead of driving it in another batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
const parent = rowWithHeight(180)
|
||||
const idle = vi.fn()
|
||||
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
|
||||
await Promise.resolve()
|
||||
subscriptions.add(motion.onFoldIdle(idle))
|
||||
vi.advanceTimersByTime(32)
|
||||
// A box under a `hidden="until-found"` ancestor keeps reporting its own size in the browser.
|
||||
const child = rowWithHeight(60, parent)
|
||||
const childDone = vi.fn()
|
||||
motion.collapse(child, childDone)
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(calls.begin).toHaveBeenCalledTimes(1)
|
||||
expect(calls.finish).toHaveBeenCalledTimes(1)
|
||||
expect(childDone).toHaveBeenCalledTimes(1)
|
||||
expectCleared(child)
|
||||
expect(idle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a held reveal on its own transition when no held closing row still occupies the flow', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
const parent = rowWithHeight(180)
|
||||
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(32)
|
||||
motion.collapse(rowWithHeight(60, parent), vi.fn())
|
||||
const opening = rowWithHeight(40)
|
||||
motion.reveal(opening)
|
||||
expect(opening.style.height).toBe('0px')
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(calls.begin).toHaveBeenCalledTimes(1)
|
||||
expect(opening.style.transition).toContain('height')
|
||||
expect(opening.style.height).toBe('40px')
|
||||
vi.runAllTimers()
|
||||
expectCleared(opening)
|
||||
})
|
||||
|
||||
it('reports reader cancellation to the driver and lets a newer request resume the target', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('RunningWhaleTail', () => {
|
||||
})
|
||||
|
||||
it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => {
|
||||
const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/[email protected]'))
|
||||
const png = readFileSync(resolve(import.meta.dirname, '../src/client/assets/[email protected]'))
|
||||
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const chunks: { type: string; data: Buffer }[] = []
|
||||
for (let offset = 8; offset < png.length;) {
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/')
|
||||
const whaleImage = fileURLToPath(new URL('./src/client/chat/[email protected]', import.meta.url))
|
||||
|
||||
export default ((options) => bundle(options).map(config => ({
|
||||
...config,
|
||||
plugins: [...(config.plugins ?? []), {
|
||||
name: 'chat-whale-image',
|
||||
async transform(code: string, id: string) {
|
||||
if (id.replaceAll('\\', '/') !== `\0dsh-css:${stylesheet}.mjs`) return null
|
||||
const imageUrl = './[email protected]'
|
||||
if (!code.includes(imageUrl)) return null
|
||||
this.addWatchFile(whaleImage)
|
||||
const image = await readFile(whaleImage)
|
||||
return { code: code.replaceAll(imageUrl, `data:image/png;base64,${image.toString('base64')}`), map: null }
|
||||
},
|
||||
}],
|
||||
}))) satisfies typeof bundle
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 4a2fcdcf13276fef
|
||||
zh: 8bde03bbbf15840c
|
||||
/deepseek-ai-dsh-client-ui-deliverables/use-this-package/explicit-deliveries:
|
||||
en: 0ee4bbe45bb5e473
|
||||
zh: 9c99d829b25f59ba
|
||||
en: 6ae1652a01354a6f
|
||||
zh: baa44d415d7a9f0e
|
||||
/deepseek-ai-dsh-client-ui-deliverables/use-this-package/the-changed-files-card:
|
||||
en: 4b279e2bddddc083
|
||||
zh: a3135393299c935b
|
||||
@@ -30,26 +30,26 @@
|
||||
en: ef13aac8ac36a5c3
|
||||
zh: 1e2ae4b3eaf98818
|
||||
/deepseek-ai-dsh-client-ui-deliverables/understand-the-implementation:
|
||||
en: 13e06395a2c447bf
|
||||
zh: c2c29bbfbfabe756
|
||||
en: 7ee5ee783902662c
|
||||
zh: fdf2c244fb22b8ab
|
||||
/deepseek-ai-dsh-client-ui-deliverables/further-exploration:
|
||||
en: 476b59e8671db982
|
||||
zh: b3d0495a03e8940a
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/clickable-file-reference-guidance:
|
||||
en: eb244aa21a4e5210
|
||||
zh: e3450addfa19cea5
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/clickable-file-reference-guidance/what-the-model-sees:
|
||||
en: 78339e353ae47a53
|
||||
zh: 582efa623a5dfd72
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/clickable-file-reference-guidance/token-effect:
|
||||
en: 66bd094b37a29c22
|
||||
zh: e657d66ac6f95a22
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/clickable-file-reference-guidance/kv-cache-effect:
|
||||
en: 52626a05acc8b320
|
||||
zh: 6a8e62b0d47d5297
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/desktop-file-references:
|
||||
en: 18f9c56e10a44603
|
||||
zh: b774e6e5f2a5db6d
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/desktop-file-references/what-the-model-sees:
|
||||
en: 635d8304709cfbc9
|
||||
zh: 8ee33c6046743fb7
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/desktop-file-references/token-effect:
|
||||
en: 2f1bfc1df1d8d8f3
|
||||
zh: 4af1b4599f3d6a6a
|
||||
/deepseek-ai-dsh-client-ui-deliverables/model-experience/desktop-file-references/kv-cache-effect:
|
||||
en: 4dc7674aa58029a5
|
||||
zh: 8b605cd009e60775
|
||||
/deepseek-ai-dsh-client-ui-deliverables/known-limitations-and-deferred-work:
|
||||
en: dcebaf6fd7885452
|
||||
zh: 017ef058d4dcdc25
|
||||
|
||||
@@ -34,7 +34,7 @@ Changed-file and delivery entry cards and hover previews follow the [R16 preview
|
||||
|
||||
The `present` tool's preparing stage is one non-expandable document-icon row. It does not show filenames, create delivery cards, or enable file actions before the recorded call supplies arguments.
|
||||
|
||||
The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply.
|
||||
The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply.
|
||||
|
||||
The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop.
|
||||
|
||||
@@ -64,7 +64,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The Node half registers the static `ui:deliverable-file-references` system-prompt section described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into recorded file targets and exact argument aliases after validating their raw arguments; the produced paths feed only the prose mention resolver. Successful absolute `meta.path` values identify those targets. Root results without that field retain the legacy argument fallback; malformed relative targets contribute no guessed path. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
|
||||
The Node half serves authenticated native file opens; the shared guidance comes from the separately mounted file-reference plugin described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into recorded file targets and exact argument aliases after validating their raw arguments; the produced paths feed only the prose mention resolver. Successful absolute `meta.path` values identify those targets. Root results without that field retain the legacy argument fallback; malformed relative targets contribute no guessed path. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
|
||||
|
||||
Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
|
||||
|
||||
@@ -89,19 +89,19 @@ Read these pages when the deliverables surface is not enough. They move from the
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
### Clickable file-reference guidance
|
||||
### Desktop file references
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The guidance prefers primary results in the final reply: inline images support explanations and comparisons, image links serve references and file lists, and image file cards serve separate deliverables. File destinations use angle brackets, including paths with spaces. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with Coding Tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`.
|
||||
The Web composition mounts the shared [`dsh-deliverable-file-references`](../../deliverables/deliverable-file-references/README.md#model-experience) guidance. This UI package renders the resulting file references and cards.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One fixed paragraph containing output-selection, rendering, and file-reference guidance whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text.
|
||||
No direct tokens; the separate prompt plugin owns the guidance.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The section is static at first-party order 9000 for the lifetime of the package mount, so it remains in the reusable prompt prefix and does not change across Turns.
|
||||
No direct effect on model requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ kind: "package-reference"
|
||||
|
||||
`present` 工具的准备阶段是带文档图标的不可展开单行。记录的调用提供参数前,该行不显示文件名、不创建交付卡片,也不启用文件操作。
|
||||
|
||||
Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。
|
||||
Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。
|
||||
|
||||
`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。
|
||||
|
||||
@@ -64,7 +64,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数与结果记录的绝对 `meta.path` 把成功的第一方修改调用折叠为产出路径及精确参数别名;产出路径只供正文提及解析器使用。不带该字段的成功根调用保留历史参数回退;格式错误的相对目标不产生猜测路径。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
|
||||
Node 半部提供经过身份验证的原生文件打开;共用指导由[模型体验](#model-experience)所述的独立文件引用插件提供。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数与结果记录的绝对 `meta.path` 把成功的第一方修改调用折叠为产出路径及精确参数别名;产出路径只供正文提及解析器使用。不带该字段的成功根调用保留历史参数回退;格式错误的相对目标不产生猜测路径。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
|
||||
|
||||
原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
|
||||
|
||||
@@ -89,19 +89,19 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
### 可点击文件引用指引
|
||||
### 桌面文件引用
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提示词建议在最终回复中展示主要结果:内嵌图片用于讲解与对比,图片链接用于引用和文件列表,图片文件卡片用于独立交付。文件目标路径使用尖括号包裹,包括含空格的路径。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。代码工作工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。
|
||||
Web 组合挂载共用的 [`dsh-deliverable-file-references`](../../deliverables/deliverable-file-references/README.zh.md#model-experience) 指导。本 UI 包渲染相应的文件引用和卡片。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
加载本包时增加一段包含产出选择、渲染效果和文件引用指导的固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
|
||||
不直接增加 token;指导由单独的 prompt 插件负责。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该段落在本包挂载期间始终以 first-party 顺序 9000 保持静态,因此留在可复用的提示词前缀中,不会随轮次改变。
|
||||
不直接影响模型请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* Deliverables plugin, node half. Registers Web file-reference guidance and
|
||||
* serves authenticated native opens of declared files. The browser
|
||||
* Deliverables plugin, node half. Serves authenticated native opens of declared files. The browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-workspace-changes/types'
|
||||
import { registerPresentOpen } from './present-open.ts'
|
||||
|
||||
/** Services required for file-reference guidance, change summaries, and authenticated native opens. */
|
||||
export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges']
|
||||
|
||||
/** Static Web guidance for primary outputs and existing-file references. */
|
||||
const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. '
|
||||
+ 'Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. '
|
||||
+ 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. '
|
||||
+ 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. '
|
||||
+ 'Avoid repeating results already shown inline unless the separate card adds useful access. '
|
||||
+ 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. '
|
||||
+ 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.'
|
||||
/** Services required for change summaries and authenticated native opens. */
|
||||
export const inject = ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges']
|
||||
|
||||
/**
|
||||
* Register Web file-reference guidance and native opens for declared files.
|
||||
* @param ctx - host context carrying the system-prompt registry.
|
||||
* Register native opens for declared files.
|
||||
* @param ctx - host context carrying file and connection services.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
registerPresentOpen(ctx)
|
||||
ctx.systemPrompt.section({
|
||||
name: 'ui:deliverable-file-references',
|
||||
order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'),
|
||||
text: FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
|
||||
import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerPresentOpen } from '../src/present-open.ts'
|
||||
import * as deliverables from '../src/index.ts'
|
||||
import {
|
||||
changedFileUrl, changesDiffUrl, changesSummaryUrl, CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesDiff,
|
||||
isChangesEvent, isChangesSummary,
|
||||
@@ -64,10 +64,7 @@ async function fixture() {
|
||||
const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }])
|
||||
ctx.provide('sessionController', { workspacePathApplications: applications, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
|
||||
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
|
||||
await ctx.plugin({
|
||||
inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'],
|
||||
apply: registerPresentOpen,
|
||||
})
|
||||
await ctx.plugin(deliverables)
|
||||
const handler = connection.createSharedFetchHandler('/api')
|
||||
const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }), connection.browserPeer)
|
||||
const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`), connection.browserPeer)
|
||||
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user