Merge remote-tracking branch 'origin/master' into refactor/subagent

# Conflicts:
#	docs/capability-seams.i18n.yaml
#	docs/capability-seams.md
#	docs/capability-seams.zh.md
This commit is contained in:
Dudu-0223 committed 2026-09-21 16:20:37 +08:00
commit 96251d06c6
291 files changed
+12826 -1802

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
2026-09-08-document-preview-operations.md: 0b90c17ba94d0c7cd30afb0a6e6a033f8f12d0df
2026-09-08-document-preview-operations.zh.md: ec9c208c97dfe75afdb0915c655d9c1c7b4d42ff
2026-09-08-document-preview-operations.md: 220db6db0474f244f902cdeb89b297856243672b
2026-09-08-document-preview-operations.zh.md: 7b838711e5118b8fcc072e4efb753abfc9756b56
@@ -24,7 +24,7 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat
## Alternatives considered
**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and delegates reads and store updates to its private injected face. The face reports the displayed source version or a failed load to the shared owner. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
@@ -24,7 +24,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界
## 考虑过的替代方案
**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,并将读取与 store 更新交给其私有注入 face。face 向共享 owner 报告已展示的源版本或加载失败。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
2026-09-11-node-office-kit.md: 132ac8b3dd709f9f16254a5569dec59f01d29fda
2026-09-11-node-office-kit.zh.md: c7bdab2322766c42cfee4092f158d7b6b530e875
2026-09-11-node-office-kit.md: 36897178014daa839399adb5fdf72a4ca53cec8b
2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a
@@ -10,11 +10,11 @@ Binary Office and OOXML files require document layout before they can be preview
## Decision
The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime.
The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares the conversion service and shared Document Preview entry with stable IDs. The service owns authorized conversion Remote methods, whose descriptor is mounted by `api/remotes`; Office UI shares the Document Preview Loader lifetime.
The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. Client PDF previews use this path for Word and PowerPoint; [browser spreadsheet preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX, XLS, CSV, and TSV independently. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes.
@@ -10,11 +10,11 @@ Status: implemented
## 决策
[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。
[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明转换服务与共享文档预览入口。服务负责带授权的转换 Remote 方法,其描述符由 `api/remotes` 挂载;Office UI 共享文档预览的 Loader 生命周期。
[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
服务和 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器表格预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 XLSX、XLS、CSV 和 TSV。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md
2026-09-16-experimental-voice-input.md: 30c81ab233f27242dcf1fe2ef89bbb86542f9c04
2026-09-16-experimental-voice-input.zh.md: 76aa8d6fc4bea32f194b20d05db4bf49af0be313
@@ -0,0 +1,35 @@
# Agent Note: Experimental voice input with explicit recognition providers
Status: implemented
English | [中文](2026-09-16-experimental-voice-input.zh.md)
## Problem
Dictation must preserve user control over when a coding task starts. Local recognition brings model files, a native runtime and CPU work; merely installing or enabling a UI contribution must not allocate these resources. Later cloud providers need a selection mechanism that does not silently change where recordings are processed.
## Decision
Five public packages under `packages/experimental/` compose one optional, default-disabled voice input bundle. Service Definition, SenseVoice Provider and Remote Consumer are separate because provider deployment and browser transport evolve independently. The browser UI dynamically mounts its generated Remote contribution. Stable API Remotes do not acquire an experimental dependency.
Provider ids select exact registrations. Resolution captures an instance, and deregistration closes admission, cancels and joins accepted work. There is no provider fallback: a local failure cannot authorize uploading speech to a cloud service. A new cloud provider supplies its own credentials and explicit processing location through the same registry.
Recordings and transcripts remain transient until the user submits ordinary text. Recognition does not start an agent turn or create an audio Session event. The input facade accepts asynchronous plain text against a captured draft revision, preserving reference chips and undo history. Changed drafts retain the recognized text for explicit insertion instead of replacing later edits. Session changes and plugin disposal invalidate late results and release recording resources. Capture failures notify the current recording activity immediately, independently of resource-close latency. Cancellation, failure and plugin withdrawal join one release promise, so ownership lasts until the AudioContext closes.
SenseVoice ONNX and Silero VAD run in one managed CPU subprocess. The native sherpa-onnx package includes ONNX Runtime; explicit preparation only downloads and verifies models; subsequent calls reuse the warm model until the configured idle deadline. Every activation verifies cached assets and restores complete caches to standby without starting the worker. Readiness therefore follows disk contents rather than the lifetime of a Provider instance. Recordings wait in the bounded queue during wake-up. The worker checks readiness at execution, so queued work cannot implicitly download resources invalidated by an earlier cache inspection. Inspection uses the same owned task as preparation, so cancellation and disposal join it. Disabling the provider joins its work and process exit while retaining reusable disk assets. Native browser recording and resampling avoid an additional audio conversion executable. The authenticated private loopback protocol carries complete PCM recordings, allowing cancellation to terminate the worker without sharing a long-running inference with another request.
The Host owns preparation independently of the view that started it. An ordered step list survives view remounts and records completion only as execution advances. Recognition weights, VAD and final verification have separate operations; downloads use pinned byte counts, while other work reports elapsed time. Preparation status and progress stay in bundle details through `plugins.bundle.config`, keyed by package name. The generic `plugins.bundle.activation` slot carries explicit enablement intent and detail navigation; the voice occupant waits for Host cache inspection and offers installation guidance only for unprepared local models. Enabling an already prepared provider does not prompt or download. A single composer activity Slot expands the voice controls while preserving the draft editor and submit action. The context meter sits below the composer; recording and transcription hide the meter along with ordinary toolbar controls until the activity closes. Shared primitives provide buttons, disclosure and status markers; only measured waveform drawing belongs to the voice UI. Recordings start on click and transcribe on Stop, with feedback inside the activity. Persistent preferences remain in plugin settings.
## Alternatives considered
Always loading the model on plugin activation would make a UI toggle incur substantial startup time and memory even without dictation. Python/FunASR/PyTorch adds interpreter provisioning, dependency installation and disk cost; completed ONNX artifacts and platform-native packages serve CPU dictation. End users never export models. Browser-native speech recognition does not provide a uniform local-processing guarantee. Streaming voice interaction would require partial-result revision, turn arbitration and bidirectional audio semantics that complete-recording dictation does not need.
## Consequences
First use needs download time and disk space. Local means the Host machine, including a remote Host. A cancelled active inference loses its warm worker and the next request reloads it. Optional preparation paths support existing offline environments without silently changing defaults.
Verification covers exact-provider routing, registration teardown, bounded audio parsing, microphone release, stale-draft and Session-switch handling, worker queue/deadline recovery, and verified download publication. Real local inference uses the same managed worker as the product. Browser composition verifies optional enablement and draft insertion; ordinary Session replay verifies that only the submitted text reaches the model.
Real Node 24 and Electron 44 children complete FP32 and INT8 inference. Electron copies VAD output to avoid external-buffer restrictions. Three runs of the 4.2-second public Chinese sample agree; this short sample is not a complete accuracy evaluation. Desktop microphone requests are restricted to audio from the primary application window, with macOS authorization handled by the operating system. Windows, Linux and signed installers still require platform acceptance.
INT8 is the default to reduce download and storage cost; `precision: fp32` selects the reference weights. Public English and concatenated bilingual samples show word differences between precisions, so quantization is not treated as accuracy-equivalent. A roughly 101-second repeated-speech sample completes in both runtimes; its observed post-request RSS is not a peak-memory bound.
@@ -0,0 +1,35 @@
# Agent Note:使用显式识别 Provider 的实验性语音输入
Status: implemented
[English](2026-09-16-experimental-voice-input.md) | 中文
## 问题
听写必须保留用户对编码任务启动时机的控制。本地识别带来模型文件、原生运行时和 CPU 工作;仅安装或启用 UI 贡献不应分配这些资源。之后的云端 Provider 需要明确的选择机制,不能悄悄改变录音的处理位置。
## 决策
`packages/experimental/` 下的五个公开包组合成一个默认禁用的可选语音输入 Bundle。服务定义、SenseVoice Provider 和 Remote 消费者分开,因为 Provider 部署与浏览器传输独立演化。浏览器 UI 动态挂载其生成的 Remote 贡献。稳定 API Remotes 不引入实验性依赖。
Provider id 选择确切的注册。解析捕获实例,注销先拒绝新任务,再取消并等待已接收的任务。不存在 Provider 回退:本地失败不能授权将语音上传至云端服务。新增云端 Provider 通过同一注册表提供自己的凭据配置与显式处理位置。
录音与转写在用户提交普通文字前都是临时数据。识别不启动智能体轮次,也不创建音频 Session 事件。输入门面按照捕获的草稿版本接收异步纯文本,保留引用芯片与撤销历史。草稿变化时保留识别文字,等待显式插入,而非替换之后的编辑。切换 Session 和释放插件会使迟到结果失效并释放录音资源。采集失败立即通知当前录音活动,不受资源关闭耗时影响。取消、失败和插件撤销共同等待同一个释放 Promise,所有权持续到 AudioContext 关闭。
SenseVoice ONNX 与 Silero VAD 在一个托管 CPU 子进程中运行。原生 sherpa-onnx 包包含 ONNX Runtime;显式准备只下载并校验模型;后续调用复用预热模型,直到配置的空闲截止时间。每次启用都会校验缓存资源,完整缓存恢复为待唤醒状态,不启动工作进程。就绪状态因此取决于磁盘内容,而非 Provider 实例的存活时间。录音在唤醒期间进入有界队列等待。工作进程在执行时检查就绪状态,因此较早的缓存检查使资源失效后,排队任务也不能隐式下载资源。检查复用准备任务的生命周期,取消与释放都会等待检查结束。禁用 Provider 等待其任务和进程退出,同时保留可复用的磁盘资源。浏览器原生录音与重采样避免增加音频转换可执行文件。带认证的私有环回协议传输完整 PCM 录音,取消时可以终止工作进程,而不与另一请求共享长时间推理。
Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和提交按钮。空闲时上下文用量按钮位于输入框下方;录音和转写期间,用量按钮与普通工具栏控件一起隐藏,关闭活动后恢复。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。
## 考虑的替代方案
在插件启用时加载模型,会让一个 UI 开关在尚未听写时就产生大量启动时间和内存占用。Python/FunASR/PyTorch 增加解释器、依赖安装与磁盘成本;ONNX 成品和平台原生包满足 CPU 听写。用户端不执行模型导出。浏览器原生语音识别不能提供一致的本地处理保证。流式语音交互需要部分结果修订、轮次仲裁与双向音频语义,而完整录音听写无需这些机制。
## 影响与验证
首次使用需要下载时间和磁盘空间。本地指 Host 所在机器,包括远程 Host。取消运行中的推理会丢失预热进程,下次请求重新加载。可选准备路径支持已有离线环境,不会悄悄改变默认行为。
验证覆盖精确 Provider 路由、注册释放、有界音频解析、麦克风释放、过期草稿和 Session 切换处理、工作进程队列与截止时间恢复,以及经过校验的下载发布。真实本地推理使用与产品相同的托管工作进程。浏览器组合验证可选启用与草稿插入;普通 Session 回放验证只有提交的文字进入模型。
Node 24 与 Electron 44 的真实子进程均完成 FP32 和 INT8 识别。Electron 的 VAD 输出使用复制缓冲区,避免 external buffer 限制。4.2 秒公开中文样本的三次识别结果一致;该短样本不代表完整准确率评测。桌面麦克风请求限制为主应用窗口的音频请求,macOS 权限由系统处理。Windows、Linux 真实机器及签名后的安装包仍需平台验收。
INT8 以降低下载和存储成本为默认值,`precision: fp32` 选择参考权重。公开英文与拼接的中英双语样本显示两种精度存在用词差异,因此不将量化视为准确率等效。约 101 秒的重复语音样本在两种运行时均完成识别;请求后的 RSS 观测不构成峰值内存上限。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md
2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7
2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008
2026-09-18-agent-teams-single-bundle.md: b417061d24928877d8d31739e1e3bde6b8512d51
2026-09-18-agent-teams-single-bundle.zh.md: ed20dac79f39f6648767d57556ed97b42d0e1c65
@@ -30,4 +30,4 @@ Existing profiles that select the removed Web bundle have an upgrade compatibili
## Verification
The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle.
The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers the read-only task board; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle.
@@ -30,4 +30,4 @@ Status: implemented
## 验证
插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。
插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖只读任务看板;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602
2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0
@@ -0,0 +1,155 @@
# Agent Note: Definition-owned grouping with generic Node/Group assembly
Status: implemented
English | [中文](2026-09-21-conversation-build-groups.zh.md)
## Problem
Chat needs expandable groups for continuous process content while keeping replies, user input, and Turn controls independent. Compact, Detailed, and Expanded must share grouping results; changing modes must not rebuild React instances by removing group containers, moving member parents, or changing keys.
A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat process projector there makes the Builder own business segmentation, even if some caching moves into generic infrastructure. Business Definitions are the registration point for these rules.
Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted.
## Decision
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
### Responsibilities
| Requirement | Consequence |
|---|---|
| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. |
| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. |
| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. |
| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. |
| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. |
| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. |
| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. |
### Related decisions
| Record | Relationship |
|---|---|
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. |
| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. |
These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced.
## Definition input, state, and registration
`ConversationNodeDefinition` keeps `match/start/update/publication/buildLocationData/buildViewNode`. `ConversationViewDefinition` keeps `target/create/isActive/toolCallFocus`. Group business is registered through `ctx.uiConversation.groups.register(processGroupDefinition)`, not a callback on either existing Definition.
- One Group Definition per target owns the complete grouping semantics. Duplicate target registration fails rather than selecting a winner or stacking strategies.
- Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state.
- Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state.
- `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output.
- `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers; `apply` also supplies projected previous/current Nodes, `changedTurns`, and `changedTurnOrders`. Readers are valid during the synchronous call only and must not be retained in State.
- Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections.
- `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field.
- The target position index supplies `changedTurnOrders` for visible-key, owning-Turn, or immediate-neighbour changes, including both owners of a moved Node and Turns affected by adjacent unscoped Nodes. `readTurn` returns one Turn's visible keys; `readPosition` returns `GroupNodePosition` with the owning Turn and immediate previous/next keys. Turn-only reads omit interruptions by unscoped Nodes; neighbour facts let business segmentation retain them. The index provides positions, not grouping decisions.
- The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions.
## Node/Group references and updates
`NodeKey` names an existing Context Node; `GroupKey` identifies a group within a Session, target, and Definition. `NodeReference` and `GroupReference` have equal standing in `RenderEntry`. `GroupSnapshot<Data>` stores a group's immutable data and ordered NodeReference members separately from root order. Group creation adds neither Session events nor an execution lifecycle.
`groupPart` is a renderer-owned part such as reasoning or response; omission selects the whole Node. A part may also appear at the root. Part completeness and content ownership belong to the business renderer, not the framework. Group data describes status, counts, or summaries, without copying Node bodies, tool results, or mutable business State. A group has no renderer field, CSS, coordinates, React elements, navigation, or nested groups.
| Update | Meaning |
|---|---|
| Omit `entries` on apply input | Keep root order; replacement input requires the complete sequence. |
| Supply `entries`, including `[]` | Replace the complete root sequence; an empty array clears it. |
| `groups: { kind: 'replace', snapshots }` | Replace complete group records, removing omitted groups. |
| `groups: { kind: 'apply', upserts, removes }` | Upsert complete snapshots and remove named GroupKeys only. |
| Data-only change | Upsert the group with new data, reuse members, omit entries. |
| Member-only change | Upsert that group with its complete new member array; no all-group replacement. |
| Root-only change | Supply entries with empty apply upserts/removes. |
| Dissolve a group | Replace root entries with its members and remove the GroupKey atomically. Source Nodes remain. |
`entries` and `groups` are one installation batch, not independent publication channels. Removing a member reference does not delete a Node. Upserts need snapshot contents; removals need only identity. React may remount a member after a real membership change, but mode switches must not dissolve groups.
The store validates the submitted final result before mutation: every root Group has exactly one record and root position; Node references exist; duplicate upserts/removals and simultaneous upsert/removal fail. Across all roots and members, each `(NodeKey, groupPart)` appears at most once. A whole Node cannot coexist with any of its parts, but different parts may occupy different positions. Atomic moves between positions are allowed.
GroupStore indexes records and sources by GroupKey, not repeated array searches. Equivalent root/member arrays and unchanged group snapshots retain identity. Data-only upserts inspect only submitted snapshots, without scanning roots, unchanged members, or Nodes. Full replacement revalidates references and still aligns results by key. Definition caches own business calculation; GroupStore owns installed results and notifications.
## Assembly order and lifecycle
1. BoundConversation receives append, prepend, replace, or Assistant settlement from the existing feed.
2. The assembler runs existing Node Definition matching, state updates, and required Context replay.
3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription.
4. Flush materializes Step then Turn Location data and target Nodes.
5. Builder.replace/apply installs projected Nodes, target order, and indexes, records position changes, and retains GroupInput with its indexed readers without notifying independent sources.
6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State.
7. It calls buildGroups, validates and installs the result, and installs the target snapshot.
8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data.
9. BoundConversation publishes the existing root Conversation source.
First activation's replaceView uses the same target update function as ordinary flush. Inactive targets create neither Builder nor Group context. Registration rebuild takes the View and Group Definitions together, discards replaced/removed Group contexts, and clears old observed output. Surviving Definitions reuse keyed stores. React receives the Node Store identity so replacement rebinds keyed hooks without replacing keys or DOM instances.
Removing a registered View Definition pauses its grouping and clears derived output, while retaining the Group Definition registration. Restoring the View uses the existing replacement flow to rebuild from the current loaded timeline. Initial Group registration still rejects a missing View target; switching tabs does not unregister a View.
Grouping never replays raw events. Node Definitions handle replay first; grouping consumes the resulting target replacement or changes. Prepend may repair boundaries and legitimately move members. Definition handles old/new Locations and changed Turns instead of treating pagination or every content chunk as a complete-history regroup.
## Concrete implementation locations
| Layer/file | Responsibility |
|---|---|
| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition, input, references, updates, typed data map, and readers. |
| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Optional Builder groupInput/publish and snapshot-store grouped reader; existing Node/View Definitions stay unchanged. |
| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | Existing registry base, target uniqueness, typed registration, effect/disposer lifecycle. |
| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups and existing binding rebuild notifications. |
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. |
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. |
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. |
## React reading and presentation
The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable.
React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch.
The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
## Alternatives considered
**Builder-owned buildGroups or process projector.** Rejected: the target aggregator would still own business rules. Moving caches into infrastructure does not change that ownership.
**Add buildGroups to the existing event Definition without new input.** Rejected: the callback name does not provide cross-Node input, lifecycle changes, or reconstruction semantics.
**Replay raw events in the Group Definition.** Rejected: it duplicates Assistant/Tool/Message interpretation and can diverge from the Builder's final visible order.
**ConversationViewDefinition.buildLayout.** Rejected: it broadens a target factory into a business layout callback rather than registering a distinct business Definition.
**One event Context per Group.** Rejected: boundaries depend on preceding visible content, not a standalone event carrying the group's start identity. A target-local derived context also admits independent Nodes outside Turns.
**Dependency graphs, ViewItem classes, factories, nested groups, or strategy stacking.** Rejected for this phase: one target input and one non-nested grouping output cover the stated need.
**Separate data-only mutation operation.** Rejected: an immutable snapshot upsert with reused members already updates summaries without replacing other groups.
**Let Group Definition add/remove both Node and Group entities.** Rejected: Node data already has an owner. Symmetrical references do not require a second Node owner.
**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive.
## Verification
- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes.
- [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources.
- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches.
- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation.
## Consequences
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
- Target-local State does not automatically make business updates local. Node changes and changed Turn orders let a Definition choose affected groups and ranges. Structural changes still rebuild the target position index over visible order; content-only updates do not rebuild it. Structural grouping output still replaces the complete root-reference array.
- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure.
- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made.
- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes.
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups.
@@ -0,0 +1,155 @@
# Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出
Status: implemented
[English](2026-09-21-conversation-build-groups.md) | 中文
## Problem
Chat 需要把连续过程内容收进可展开的组,同时保留独立回复、用户输入和轮次控件。Compact、Detailed、Expanded 必须共用分组结果;切换模式不能通过删除组容器、移动成员父级或更换 key 重建 React 实例。
Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程投影器,即使把部分缓存移到通用层,仍然让 Builder 拥有业务分段。业务 Definition 才是这些规则的注册点。
过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。
## Decision
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
### 职责
| 要求 | 含义 |
|---|---|
| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 |
| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 |
| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 |
| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 |
| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 |
| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 |
| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 |
### 与已有记录的关系
| 记录 | 关系 |
|---|---|
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 |
| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 |
这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。
## Definition 输入、状态与注册
`ConversationNodeDefinition` 保留 `match/start/update/publication/buildLocationData/buildViewNode`,`ConversationViewDefinition` 保留 `target/create/isActive/toolCallFocus`。分组业务通过 `ctx.uiConversation.groups.register(processGroupDefinition)` 注册,不加在上述已有 Definition 的回调中。
- 每个目标只有一个 Group Definition,拥有完整分组语义。重复目标注册报错,不选择胜者,也不叠加策略。
- 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。
- 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。
- `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。
- `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器;`apply` 还提供投影后的节点前后值、`changedTurns` 和 `changedTurnOrders`。读取器仅在当前同步调用中有效,不保留到 State。
- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。
- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。
- 目标位置索引通过 `changedTurnOrders` 提供可见键、所属 Turn 或紧邻关系的变化,包含移动 Node 的新旧所属轮次,以及受相邻轮次外 Node 影响的轮次。`readTurn` 返回某个 Turn 的可见键,`readPosition` 返回包含所属 Turn 和前后紧邻键的 `GroupNodePosition`。仅按 Turn 读取会遗漏轮次外 Node 造成的分隔,业务分段可以通过相邻事实保留这些分隔。索引提供位置,不决定分组。
- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。
## Node/Group 引用与更新
`NodeKey` 表示既有 Context Node,`GroupKey` 在 Session、目标及 Definition 内标识组。`NodeReference` 与 `GroupReference` 在 `RenderEntry` 中地位相同。`GroupSnapshot<Data>` 单独保存组的不可变数据与有序 NodeReference 成员,不混入根顺序。创建 Group 不增加 Session 事件或执行生命周期。
`groupPart` 是渲染器拥有的节点部分,例如 reasoning 或 response,省略表示整个 Node。部分引用也可以独立位于根层。部分内容的完备性和归属由业务渲染器负责,不由框架判断。组数据描述状态、计数或摘要,不复制 Node 正文、工具结果或可变业务 State。组不携带 renderer 字段、CSS、坐标、React 元素、导航或嵌套组。
| 更新 | 含义 |
|---|---|
| apply 输入省略 `entries` | 保留根顺序;替换输入必须提供完整序列。 |
| 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 |
| `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 |
| `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 |
| 仅数据变化 | upsert 新 data,复用 members,省略 entries。 |
| 仅成员变化 | upsert 该组完整的新成员数组,不替换全部组。 |
| 仅根顺序变化 | 提供 entries,apply 的 upserts/removes 为空。 |
| 解散一个组 | 原子地用成员替换根引用并删除 GroupKey,保留原 Node。 |
`entries` 与 `groups` 属于同一安装批次,不是独立发布通道。删除成员引用不删除 Node。upsert 需要快照内容,删除只需要身份。真实成员归属变化可以让 React 重挂载成员,但模式切换不能解散组。
存储在修改前校验最终提交结果:每个根 Group 恰有一份记录和一个根位置;Node 引用存在;重复 upsert、重复删除及同时 upsert/删除均报错。全部根和成员中,每个 `(NodeKey, groupPart)` 最多出现一次。整 Node 不能与自身任一部分共存,不同部分可以占据不同位置。允许引用在一次原子更新中移动位置。
GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价根数组、成员数组及未变化组快照保留身份。仅数据 upsert 只检查提交的快照,不扫描根、未变化成员或 Node。完整替换重新校验引用,仍按 key 对齐结果。Definition 缓存拥有业务计算,GroupStore 拥有已安装结果及通知。
## 组装时序与生命周期
1. BoundConversation 从现有 feed 接收 append、prepend、replace 或 Assistant settlement。
2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。
3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。
4. flush 依次物化 Step、Turn Location 数据及目标 Node。
5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。
6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。
7. 调用 buildGroups,校验并安装结果,安装目标快照。
8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。
9. BoundConversation 发布既有根 Conversation 来源。
首次激活的 replaceView 与普通 flush 使用同一目标更新函数。未激活目标不创建 Builder 或 Group 上下文。注册重建一并采纳 View 和 Group Definition,丢弃被替换或删除的 Group 上下文并清空旧观察结果。未替换的 Definition 复用按键存储。React 接收 Node Store 身份,让存储替换时重新绑定按键钩子,不替换 key 或 DOM 实例。
已注册的 View Definition 被移除后,分组计算暂停并清空派生结果,Group Definition 注册仍保留。View 恢复时沿现有替换流程,从当前已加载时间线重建。首次注册 Group 时仍拒绝缺失的 View 目标;切换页签不会注销 View。
分组不重放原始事件。Node Definition 先处理重放,分组消费其最终目标替换或变化。prepend 可以修正边界并真实移动成员。Definition 处理新旧 Location 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。
## 具体实现落点
| 层/文件 | 职责 |
|---|---|
| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition、输入、引用、更新、类型数据映射及读取器。 |
| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Builder 可选 groupInput/publish 和快照存储 grouped 读取,已有 Node/View Definition 不变。 |
| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | 复用注册表基类,目标唯一性、类型化注册及 effect/disposer 生命周期。 |
| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups 与现有 binding 重建通知。 |
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 |
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 |
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 |
## React 读取与展示
根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。
React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。
稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
## Alternatives considered
**Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。
**在原事件 Definition 加 buildGroups,不补输入。** 不采用,回调名称不能提供跨节点输入、生命周期变化或重建语义。
**Group Definition 重放原始事件。** 不采用,它重复 Assistant、Tool、Message 解释,并可能偏离 Builder 最终可见顺序。
**ConversationViewDefinition.buildLayout。** 不采用,它把目标工厂扩展成业务布局回调,没有独立注册业务 Definition。
**每个 Group 一个事件 Context。** 不采用,边界取决于前方可见内容,不由一个携带组起点身份的事件决定。目标内派生上下文也能处理 Turn 外独立节点。
**依赖图、ViewItem 类、工厂、嵌套组或策略叠加。** 本阶段不采用,一个目标输入和一份非嵌套分组输出覆盖当前诉求。
**独立的数据更新操作。** 不采用,复用 members 的不可变快照 upsert 已经能更新摘要,不替换其他组。
**Group Definition 同时增删 Node 和 Group 实体。** 不采用,Node 数据已有所有者,引用对称不需要第二个 Node 所有者。
**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。
## Verification
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
- [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。
- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。
- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
## Consequences
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435
2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8
@@ -0,0 +1,35 @@
# Agent Note: Browser Excel preview with saved formula results
Status: implemented
English | [中文](2026-09-16-browser-excel-preview.zh.md)
## Problem
PDF conversion loses spreadsheet navigation and the relationship between a formula and its saved value. Spreadsheet preview also needlessly depends on the Host Office engine when the browser can read the workbook.
## Decision
The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX, XLS, CSV, and TSV with FortuneSheet and package-local adapters for ExcelJS, SheetJS CE, and PapaParse. The adapter accepts bytes, format, and limits and returns worksheet data; it has no React state setters, timers, or component references. Dependencies own file parsing, while the adapters share display formatting and initial selection without introducing another workbook model. CSV/TSV retain literal strings and a plain-text option; implicit type inference could otherwise discard leading zeros or interpret source text as formulas. The shared document reader calls `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)` and receives native bytes through the binary Remote, retaining Session authorization, file versions, reload, and transient bytes without base64 decoding. Workspace Files owns bounded file reads; format parsing stays in the Client. The registration marks only XLSX/XLS as `binaryExtensions`, so the shared document container offers Plain text for CSV/TSV. Each renderer registers its body under its own id.
Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank. Workbooks containing formulas show a compact warning beside the formula icon instead of consuming worksheet height with a notice row. The spreadsheet is neither an authoring tool nor a full Excel rendering engine.
A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying when upgrading FortuneSheet.
## Alternatives considered
**Adopt FortuneExcel's React-oriented conversion helper.** Its component setters and post-render sizing couple file conversion to one UI lifecycle. The local adapter can be tested without mounting a workbook and needs no second intermediate workbook model.
**Write an OOXML parser or create a new public package.** ExcelJS already owns the container and file semantics. One preview consumer does not justify another public API, Cordis service, or generic workbook abstraction; a second independent consumer can motivate extraction.
**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. The [Office engine decision](../architecture/2026-09-11-node-office-kit.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets.
## Consequences
The renderer trades complete Excel fidelity for browser-local navigation and reusable conversion code. The package README owns supported formats, resource defaults, and unsupported features. Real XLSX/XLS fixtures and delimited-text cases cover conversion, Worker tests cover cancellation and cleanup, and the shipped-profile browser scenario opens a workbook with Office conversion disabled and copies a saved XLOOKUP result under read-only settings. It also exercises XLS sheet switching and CSV/TSV literal copying, text-view switching, and reload. SheetJS CE is pinned to its official release tarball because the public npm registry is stale; its Apache-2.0 license follows the existing permissive-license and bundled-notice policy.
@@ -0,0 +1,35 @@
# Agent Note:显示已保存公式结果的浏览器 Excel 预览
Status: implemented
[English](2026-09-16-browser-excel-preview.md) | 中文
## 问题
PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。浏览器能够读取工作簿时,表格预览也没有必要依赖 Host Office 引擎。
## 决策
[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS、SheetJS CE 和 PapaParse 适配层打开 XLSX、XLS、CSV 和 TSV。适配层接收字节、格式和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。依赖库负责文件解析,适配层复用显示格式化和初始选区,不引入另一种工作簿模型。CSV/TSV 保留字面字符串和纯文本选项;隐式类型推断可能丢失前导零或把源文本解释为公式。共享文档读取器调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`,并通过二进制 Remote 直接接收原生字节,保留 Session 授权、文件版本、重新载入和临时字节,无需 base64 解码。Workspace Files 负责有界文件读取,格式解析留在 Client。注册仅将 XLSX/XLS 标为 `binaryExtensions`,共享文档容器因此为 CSV/TSV 提供纯文本选项。每个渲染器以自己的 id 注册正文。
预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空。包含公式的工作簿会在公式图标旁显示紧凑提示,不再用整行提示占用工作表高度。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。
独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
## 考虑过的替代方案
**采用 FortuneExcel 面向 React 的转换辅助函数。** 其组件状态设置器和渲染后尺寸调整把文件转换耦合到一种 UI 生命周期。本地适配层无需挂载工作簿即可测试,也不需要第二种中间工作簿模型。
**自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。
**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。
## 影响
渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX/XLS fixture 和分隔文本用例覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。该场景还验证 XLS 工作表切换,以及 CSV/TSV 字面值复制、纯文本切换和重新载入。由于公共 npm 仓库版本过旧,SheetJS CE 固定使用官方发布的 tarball;其 Apache-2.0 许可证沿用现有宽松许可证及打包声明规则。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7
2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47
@@ -25,6 +25,8 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit
## Goals and constraints
The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan.
The following decisions were confirmed during design review and are inputs to this proposal.
| Number | Decision | Meaning |
@@ -25,6 +25,8 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不
## 目标与约束
[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
| 编号 | 裁决 | 含义 |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md
2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5
2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e
2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881
2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c
@@ -45,7 +45,7 @@ Verify staging against the feature's diff hunks, not whole files. Inspect the wo
| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending |
| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending |
All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
### Migration inventory without grouping dependencies
@@ -82,11 +82,11 @@ Review batches are independent of implementation and staging status. Review rema
### Requirements retained for the grouping phase
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements.
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements.
| Feature | compact | detailed | expanded | Current state |
|---|---|---|---|---|
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) |
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) |
| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes |
| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) |
| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) |
@@ -97,6 +97,38 @@ All items below remain incomplete and retain their full requirements for the tur
Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments.
### Definition-owned segmentation and presentation
Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number.
| Ordered visible input | Business action |
|---|---|
| Owning Turn changes | Close the pending group. |
| No Turn ownership | Close the group and emit an independent Node; never merge across it. |
| turn-process control | Emit independently without closing the pending process. |
| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. |
| Assistant reasoning | Append the reasoning part to the pending group. |
| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. |
| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. |
| Turn end | Close that Turn's pending groups even without Node upserts. |
Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time.
Group headers select data independently from member arrays. All modes retain the same Group container and member parents:
| Mode | Group header | Body |
|---|---|---|
| Compact | Short activity summary | Group open/closed state controls visibility. |
| Detailed | Summary plus live command/path/query details | Same group open/closed state. |
| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. |
This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration.
| Business implementation | Responsibility |
|---|---|
| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. |
| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. |
## Performance constraints
| Scenario | Property to preserve | Required evidence |
@@ -45,7 +45,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
### 不依赖分组的迁移清单
@@ -82,11 +82,11 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
### 分组阶段保留的需求
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。
| 功能 | compact | detailed | expanded | 当前状态 |
|---|---|---|---|---|
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) |
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) |
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
@@ -97,6 +97,38 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。
### Definition 拥有的分段与展示
业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。
| 有序可见输入 | 业务动作 |
|---|---|
| 所属 Turn 改变 | 关闭待完成组。 |
| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 |
| turn-process 控件 | 独立输出,不关闭待完成过程。 |
| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 |
| Assistant 推理 | 把 reasoning 部分加入待完成组。 |
| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 |
| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 |
| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 |
分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。
组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级:
| 模式 | 组头 | 正文 |
|---|---|---|
| Compact | 简短活动摘要 | 按组开合状态显隐。 |
| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 |
| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 |
以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。
| 业务实现 | 职责 |
|---|---|
| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 |
| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 |
## 性能约束
| 场景 | 要保留的性质 | 所需证据 |
+10
View File
@@ -38,6 +38,8 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT |
| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 |
| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
| [`@lexical/history`](https://github.com/facebook/lexical) | MIT |
@@ -79,7 +81,9 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
| [`exceljs`](https://github.com/exceljs/exceljs) | MIT |
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`immer`](https://github.com/immerjs/immer) | MIT |
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
@@ -103,6 +107,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
| [`open`](https://github.com/sindresorhus/open) | MIT |
| [`papaparse`](https://github.com/mholt/PapaParse) | MIT |
| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 |
| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
| [`react`](https://github.com/facebook/react) | MIT |
@@ -111,6 +116,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT |
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
@@ -120,6 +126,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`undici`](https://github.com/nodejs/undici) | MIT |
| [`use-sync-external-store`](https://github.com/facebook/react) | MIT |
| [`ws`](https://github.com/websockets/ws) | MIT |
| [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 |
| [`yaml`](https://github.com/eemeli/yaml) | ISC |
| [`zod`](https://github.com/colinhacks/zod) | MIT |
| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
@@ -128,6 +135,8 @@ pnpm applies local patches to the following packages at install time, so shipped
- `@earendil-works/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
@@ -182,6 +191,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/papaparse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+2 -1
View File
@@ -106,7 +106,8 @@
"@deepseek-ai/dsh-http-proxy": "workspace:^",
"@deepseek-ai/dsh-mcp-resources": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-atomic-write": "workspace:^"
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^"
},
"devDependencies": {
"@agentclientprotocol/sdk": "1.4.0",
+4 -1
View File
@@ -40,6 +40,7 @@
"@deepseek-ai/dsh-client-web": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
"@deepseek-ai/dsh-remote-mock": "workspace:^",
@@ -60,6 +61,8 @@
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-workspace-changes": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
"@deepseek-ai/dsh-experimental-auto-review": "workspace:^",
"@deepseek-ai/dsh-experimental-speech-to-text": "workspace:^",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^"
}
}
+78 -10
View File
@@ -8,6 +8,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import type {} from '@deepseek-ai/dsh-experimental-agent-team'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -79,26 +80,93 @@ describe('web e2e: Agent Teams panel', () => {
await scaffold?.close()
})
it('loads the roster and creates one shared task through generated Remote', async () => {
it('displays agent-owned tasks through a read-only board', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
const action = page.locator('[data-team-action]')
await action.getByRole('button', { name: /Agent Team/iu }).click()
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await action.getByText('No shared tasks yet').waitFor()
await action.getByText('lead').waitFor()
await action.getByRole('button', { name: 'New task' }).click()
await action.getByPlaceholder('Task subject').fill('Browser task')
await action.getByPlaceholder('Task description').fill('Created through the assembled browser')
await action.getByPlaceholder(/Write scopes/iu).fill('src/web')
await action.getByRole('button', { name: 'Save' }).click()
await action.getByText('Browser task').waitFor()
expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[data-team-action]', scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]!
const task = await scaffold.ctx.agentTeams.createTask(agent, {
subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'],
})
await action.getByRole('button', { name: 'Refresh Team' }).click()
await action.getByText('Agent task', { exact: true }).waitFor()
await action.getByText('Owner: Unowned', { exact: true }).waitFor()
await scaffold.ctx.agentTeams.updateTask(agent, {
taskId: task.id, expectedRevision: task.revision, action: 'claim',
})
await action.getByRole('button', { name: 'Refresh Team' }).click()
await action.getByText('In progress', { exact: true }).waitFor()
await action.getByText('Owner: lead', { exact: true }).waitFor()
expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0)
expect(await action.locator('input, select, textarea').count()).toBe(0)
const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await action.getByRole('button', { name: 'Close' }).click()
}, 60_000)
it('keeps the panel inside the viewport and closes on outside click or Escape', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard'))
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu })
const viewport = page.viewportSize()!
await trigger.click()
await panel.getByText('lead', { exact: true }).waitFor()
try {
for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }]) {
await page.setViewportSize(size)
await expect.poll(() => panel.evaluate((element) => {
const rect = element.getBoundingClientRect()
return element.parentElement === document.body
&& rect.left >= 16 && rect.top >= 16
&& rect.right <= window.innerWidth - 16 && rect.bottom <= window.innerHeight - 16
&& [[rect.left + 12, rect.top + 12], [rect.right - 12, rect.bottom - 12]]
.every(([x, y]) => element.contains(document.elementFromPoint(x!, y!)))
})).toBe(true)
}
await panel.getByText('Shared tasks', { exact: true }).click()
expect(await panel.count()).toBe(1)
await page.mouse.click(2, 2)
await panel.waitFor({ state: 'detached' })
await page.setViewportSize(viewport)
await trigger.focus()
await page.keyboard.press('Enter')
await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor()
expect(await panel.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
expect(await panel.getByRole('button', { name: 'Refresh Team' })
.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
expect(await panel.getByRole('button', { name: 'Close', exact: true })
.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await panel.waitFor({ state: 'detached' })
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await panel.waitFor()
await page.keyboard.press('Tab')
await page.keyboard.press('Tab')
await page.keyboard.press('Enter')
await panel.waitFor({ state: 'detached' })
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await panel.waitFor()
const outside = page.getByRole('button', { name: 'Settings', exact: true })
await outside.focus()
await panel.waitFor({ state: 'detached' })
expect(await outside.evaluate(element => element === document.activeElement)).toBe(true)
} finally {
await page.setViewportSize(viewport)
}
})
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md'])
})
+92
View File
@@ -32,6 +32,28 @@ async function seedRepository(cwd: string): Promise<void> {
git('commit', '-q', '-m', 'seed')
}
/** Sample the real opacity transition at its midpoint without depending on wall-clock scheduling. */
function hoverOpacityTransition(page: Page, closing: boolean): Promise<{ duration: number; opacity: number }> {
return page.evaluate(closing => new Promise((resolve) => {
const observer = new MutationObserver(() => {
const card = document.querySelector('[data-changes-hover-preview]')?.parentElement
if (card === undefined || card === null || card.hasAttribute('data-closing') !== closing) return
const initialOpacity = Number(getComputedStyle(card).opacity)
const animation = card.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity')
observer.disconnect()
if (animation === undefined) { resolve({ duration: 0, opacity: initialOpacity }); return }
animation.pause()
animation.currentTime = 50
const opacity = Number(getComputedStyle(card).opacity)
const duration = Number(animation.effect!.getTiming().duration)
animation.finish()
resolve({ duration, opacity })
})
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-closing'] })
if (closing) window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}), closing)
}
describe('web e2e: a git workspace turn ends with its changed files', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -180,6 +202,76 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
expect(tripwire.warnings).toEqual([])
})
it('previews a single column with a scrollable path and no file notes or hunk headers', async () => {
const card = page.locator('[data-changed-files]')
const row = card.getByRole('button', { name: '查看 notes.txt 的改动' })
const preview = page.locator('[data-changes-hover-preview]')
const entrance = hoverOpacityTransition(page, false)
await row.hover()
expect(await entrance).toEqual({ duration: 100, opacity: 0.5 })
await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' })
expect(await preview.locator('[data-diff-side]').count()).toBe(0)
expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false)
const geometry = await preview.evaluate((element) => {
const popup = element.parentElement!.getBoundingClientRect()
const card = document.querySelector('[data-changed-files]')!.getBoundingClientRect()
return { inset: popup.left - card.left, width: popup.width, cardWidth: card.width, height: popup.height }
})
expect(geometry.inset).toBe(24)
expect(geometry.width).toBe(geometry.cardWidth - 48)
expect(geometry.height).toBeLessThanOrEqual(420)
const path = preview.locator('[data-changes-preview-path]')
expect(await row.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe(await path.textContent())
expect(await path.getAttribute('tabindex')).toBeNull()
const pathStyle = await path.evaluate((element) => {
const style = getComputedStyle(element)
return { family: style.fontFamily, overflow: style.overflowX, whiteSpace: style.whiteSpace, ellipsis: style.textOverflow }
})
expect(pathStyle.family).toMatch(/mono/i)
expect(pathStyle).toMatchObject({ overflow: 'auto', whiteSpace: 'nowrap', ellipsis: 'clip' })
await path.evaluate((element) => { element.style.maxWidth = '160px'; element.scrollLeft = 50 })
expect(await path.evaluate(element => element.scrollLeft)).toBe(50)
await path.evaluate((element) => { element.style.removeProperty('max-width') })
await compareOrRefreshGolden(join(DIR, 'hover.expected.md'), await preview.locator('[data-review-view]').ariaSnapshot(), MODE)
await preview.hover()
await preview.locator('[data-review-view]').evaluate((element) => {
const line = element.querySelector('[data-diff-line]')!
for (let index = 0; index < 40; index++) element.appendChild(line.cloneNode(true))
})
await expect.poll(() => preview.evaluate(element => element.parentElement!.getBoundingClientRect().height)).toBe(420)
const scroll = preview.locator('[data-review-view]')
await scroll.evaluate((element) => { element.scrollTop = 80 })
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBe(80)
await row.click()
await preview.waitFor({ state: 'detached' })
const review = page.locator('[data-changes-review]')
await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
await card.getByRole('button', { name: '查看 src/util.ts 的改动' }).hover()
await preview.locator('[data-diff-code]').first().waitFor({ state: 'visible' })
expect(await preview.locator('[data-review-view="unified"]').count()).toBe(1)
expect(await preview.locator('[data-diff-note]').isVisible()).toBe(false)
// Empty comparisons keep their status; only metadata accompanying code is hidden.
await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'empty') })
expect(await preview.locator('[data-diff-note]').isVisible()).toBe(true)
await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'metadata') })
expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false)
await expect.poll(() => preview.evaluate(element => Number(getComputedStyle(element.parentElement!).opacity))).toBe(1)
expect(await hoverOpacityTransition(page, true)).toEqual({ duration: 100, opacity: 0.5 })
await preview.waitFor({ state: 'detached' })
for (const key of ['Enter', 'Space']) {
await page.mouse.move(0, 0)
await row.focus()
await row.hover()
await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' })
await row.press(key)
await preview.waitFor({ state: 'detached' })
await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => {
const card = page.locator('[data-changed-files]')
const column = page.locator('[data-rightbar-col]')
+120 -13
View File
@@ -9,6 +9,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { realOfficeBytes } from './office-fixture.ts'
import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -243,6 +245,12 @@ else process.exit(1);
writeFile(join(cwd, 'user-unit.pdf'), pdfFixture(2)),
...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))),
writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()),
writeFile(join(cwd, 'budget.xlsx'), await excelFixture()),
writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()),
writeFile(join(cwd, 'literal-html.xlsx'), await excelHtmlFixture()),
writeFile(join(cwd, 'budget.xls'), xlsFixture()),
writeFile(join(cwd, 'table.csv'), '00123,"中文,字段","=SUM(1,2)"\n2024-03-01,,TRUE'),
writeFile(join(cwd, 'table.tsv'), '00123\t"中文\t字段"\t=SUM(1,2)\n2024-03-01\t\tTRUE'),
...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))),
writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])),
])
@@ -751,16 +759,121 @@ else process.exit(1);
'- Plain-text option and viewer picker: hidden',
].join('\n'))
const spreadsheetStates: string[] = []
await openFile('budget.xlsx')
const excel = preview.locator('[data-excel-preview]')
await excel.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' })
await successShot(page, 'excel-budget')
expect(await preview.locator('[data-pdf-preview]').count()).toBe(0)
expect(await excel.locator('.fortune-toolbar').count()).toBe(0)
const sheetOverlay = excel.locator('.fortune-sheet-overlay')
const formulaInput = excel.locator('.fortune-fx-input')
await sheetOverlay.click({ position: { x: 500, y: 110 } })
await expect.poll(() => formulaInput.innerText()).toBe('=C3/B3')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('80.0%\n')
await excel.getByText('公式与格式', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await sheetOverlay.click({ position: { x: 140, y: 30 } })
await expect.poll(() => formulaInput.innerText()).toBe('46281')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('2026-09-16\n')
await sheetOverlay.click({ position: { x: 70, y: 30 } })
await expect.poll(() => formulaInput.innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})')
expect(await formulaInput.getAttribute('contenteditable')).toBe('false')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n')
await page.keyboard.type('999')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n')
await successShot(page, 'excel-cached-formula')
await openFile('meeting.xlsx')
await excel.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' })
expect(await excel.getByText('Read-only preview', { exact: false }).count()).toBe(0)
expect(await excel.getByText('Some formulas have no saved result', { exact: false }).count()).toBe(0)
const formulaWarning = excel.locator('[data-excel-formula-warning]')
await formulaWarning.hover()
await page.getByText('This workbook contains formulas. Displayed results may be missing or inaccurate.', { exact: true }).waitFor()
await excel.getByText('统计看板', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-meeting')
await openFile('budget.xls')
await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' })
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('旧版预算')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('旧版预算')
await excel.getByText('明细', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-legacy')
await openFile('literal-html.xlsx')
for (const [sheet, formula, copied] of [
['Formula', `="${excelHtmlText}"`, 'saved result'],
['Text', excelHtmlText, excelHtmlText],
['Cached text', '="cached"', excelHtmlText],
] as const) {
await excel.getByText(sheet, { exact: true }).click()
await expect.poll(() => formulaInput.textContent()).toBe(formula)
expect(await formulaInput.locator('img').count()).toBe(0)
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(`${copied}\n`)
const clipboardTable = excel.locator('#fortune-copy-content table')
expect(await clipboardTable.locator('td').textContent()).toBe(copied)
expect(await clipboardTable.locator('img').count()).toBe(0)
expect(await page.evaluate(() => document.documentElement.dataset.spreadsheetHtml)).toBeUndefined()
}
const invalidExcel = 'This spreadsheet could not be opened. Check its format, contents, or password protection.'
for (const extension of ['xls', 'xlsx']) {
await openFile(`unavailable.${extension}`)
const spreadsheet = column.locator('[data-textpreview-state="unsupported"]')
await spreadsheet.waitFor({ timeout: 15_000 })
expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1)
await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 })
spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`)
await preview.getByText(invalidExcel, { exact: true }).waitFor()
}
sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n'))
sections.push([
'## Browser Excel preview', '',
'- Opens without the Office conversion service',
'- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted',
'- Formula workbooks use a compact warning beside fx; notice rows absent',
'- Formatted percent copied: 80.0%; date copied: 2026-09-16',
'- Cached XLOOKUP result copied: 42; typing leaves it unchanged',
'- Formula bar is read-only; PDF body and editing toolbar absent',
'- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML',
'- XLS: merged title copied; worksheet selection retained',
`- Invalid XLS/XLSX: ${invalidExcel}`,
].join('\n'))
for (const extension of ['csv', 'tsv']) {
await openFile(`table.${extension}`)
await expect.poll(() => viewer.innerText()).toBe('Spreadsheet')
await excel.getByText(extension.toUpperCase(), { exact: true }).waitFor()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('00123')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('00123\n')
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('=SUM(1,2)\n')
await viewer.click()
await page.getByRole('menuitem', { name: 'Plain text', exact: true }).click()
await expect.poll(() => preview.locator('[data-textpreview-line]').count()).toBe(2)
expect((await preview.locator('[data-textpreview-line]').allTextContents()).join('\n')).toContain('00123')
await viewer.click()
await page.getByRole('menuitem', { name: 'Spreadsheet', exact: true }).click()
await excel.getByText(extension.toUpperCase(), { exact: true }).waitFor()
await writeFile(join(cwd, `table.${extension}`), extension === 'csv' ? '00999,更新' : '00999\t更新')
await preview.locator('[data-textpreview-tool="reload"]').click()
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('00999')
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('00999\n')
await successShot(page, `excel-${extension}`)
}
sections.push(['## Delimited spreadsheets', '',
'- CSV and TSV default to Spreadsheet; Plain text remains selectable',
'- Copied ID: 00123; copied literal formula: =SUM(1,2)',
'- Plain-text round trip retains complete source lines',
'- Reload replaces parsed cells: 00123 -> 00999',
].join('\n'))
await openFile('notes.unknown')
const plainLines = preview.locator('[data-textpreview-line]')
@@ -958,12 +1071,6 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await successShot(page, `office-${extension}`)
}
expect(convert).toHaveBeenCalledTimes(4)
for (const extension of ['xls', 'xlsx']) {
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 })
expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0)
}
expect(convert).toHaveBeenCalledTimes(4)
await openPreviewFile(column, filesTab, preview, 'chinese.docx')
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
@@ -3,12 +3,16 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "5"
- text: "6"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
- text: 限制每条命令最多能跑多久、最多输出多少内容。
@@ -3,12 +3,16 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "5"
- text: "6"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
- text: 限制每条命令最多能跑多久、最多输出多少内容。
@@ -3,12 +3,16 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
- text: "5"
- text: "6"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
- text: 限制每条命令最多能跑多久、最多输出多少内容。
@@ -0,0 +1,6 @@
- dialog "Set up voice input before recording":
- heading "Set up voice input before recording" [level=2]
- button "Cancel"
- paragraph: Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup.
- button "Later"
- button "Go to setup"
+2 -2
View File
@@ -87,10 +87,10 @@ describe('web e2e: plugin configuration pages', () => {
// Every page the shipped web composition exposes: the shell executor, the
// agent loop, subagent selection, and the DeepSeek search provider, after
// the official Team bundle the installation ships switched off.
// the official bundles the installation ships switched off.
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
const official = panel.locator('[data-plugin-group="official"]')
expect(await official.locator('[data-plugin-package]').count()).toBe(1)
expect(await official.locator('[data-plugin-package]').count()).toBe(2)
expect(await official.locator('[data-plugin-item]').count()).toBe(4)
for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) {
expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
+6 -4
View File
@@ -124,9 +124,10 @@ describe('web e2e: plugin manager', () => {
// group, followed by the official plugins that registered their configuration, and its other bundles
// stay off the page.
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(1)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2)
expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false')
// A bundle that is off still shows the rows its patch declares, without switches.
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
@@ -276,8 +277,9 @@ describe('web e2e: plugin manager', () => {
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
await action.waitFor({ timeout: 20_000 })
await action.getByRole('button', { name: /Agent Team/iu }).click()
await action.getByText('还没有共享任务').waitFor()
await action.getByText('lead', { exact: true }).waitFor()
const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true })
await teamPanel.getByText('还没有共享任务').waitFor()
await teamPanel.getByText('lead', { exact: true }).waitFor()
const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
dsh: { profile: { bundles: string[] } }
}
@@ -1,4 +1,3 @@
- button "Agent Team" [expanded]
- dialog "Agent Team":
- strong: Agent Team
- button "Refresh Team"
@@ -6,14 +5,8 @@
- heading "Members" [level=3]
- 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]'
- heading "Shared tasks" [level=3]
- button "New task"
- article:
- strong: Browser task
- text: Pending
- paragraph: Created through the assembled browser
- text: "task-1 Ready Write scopes: src/web Owner"
- combobox "Owner":
- option "Unowned" [selected]
- option "lead"
- button "Edit"
- button "Delete"
- strong: Agent task
- text: In progress
- paragraph: Created by the Team Lead
- text: "task-1 Owner: lead Write scopes: src/web"
+146
View File
@@ -0,0 +1,146 @@
/** Browser dictation submits only reviewed text through the ordinary recorded Session flow. */
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished, vi } from 'vitest'
import type { SpeechInput, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type {} from '@deepseek-ai/dsh-experimental-speech-to-text'
import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, webSnapshotMode, watchConsole,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const fixture = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
const expected = fileURLToPath(new URL('../../../snapshots/web/voice-input/ui.expected.md', import.meta.url))
const interruptedExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/interrupted.expected.md', import.meta.url))
const recordingExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/recording.expected.md', import.meta.url))
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submits only the reviewed transcript through Session replay', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-voice-browser-'))
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
try { await resources.browser?.close() } finally {
try { await resources.scaffold?.close() } finally { await rm(scratch, { recursive: true, force: true }) }
}
})
const overlay = join(scratch, 'voice.patch.yml')
await writeFile(overlay, '- id: speech-to-text-sensevoice\n disabled: true\n')
const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]!
const prefix = 'Use the bash tool to '
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: true }] },
extraOverlayPath: overlay, replayFixture: fixture, compareReplaySession: 'read-only',
})
resources.scaffold = scaffold
const recognize = vi.fn(async (input: SpeechInput) => {
expect(Buffer.from(input.audio).subarray(0, 4).toString()).toBe('RIFF')
expect(input.audio.byteLength).toBeGreaterThan(44)
return { text: prompt.slice(prefix.length), audioSeconds: 1, inferenceSeconds: 0.1 }
})
await scaffold.ctx.plugin({ inject: ['speechToText'], apply(ctx) {
ctx.effect(() => ctx.speechToText.register({
info: { id: 'sensevoice-local' as SpeechProviderId, name: 'Recorded recognizer', location: 'host-local', languages: ['auto'] },
preparation: {
snapshot: () => ({ phase: 'standby' }), subscribe: () => () => {},
prepare: () => { throw new Error('Cached resources must allow recording without preparation') },
cancel: async () => {},
},
transcribe: recognize,
}))
} })
let messages = 0
scaffold.ctx.on('session/event', (_session, event) => {
if (event.type === 'user/message' && event.data.source.kind === 'user') messages++
})
const browser = await chromium.launch({ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] })
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.addInitScript(() => {
const holder = window as Window & { voiceTestRecorder?: MediaRecorder }
const NativeRecorder = window.MediaRecorder
window.MediaRecorder = class extends NativeRecorder {
constructor(stream: MediaStream, options?: MediaRecorderOptions) { super(stream, options); holder.voiceTestRecorder = this }
}
})
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true })
await mic.waitFor()
const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox()
const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox()
expect(micBox!.x).toBeGreaterThan(modelBox!.x)
expect(micBox!.x).toBeLessThan(sendBox!.x)
await input.fill(prefix)
await mic.click()
await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).waitFor()
await compareOrRefreshGolden(recordingExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
expect(await page.getByRole('dialog').count()).toBe(0)
// Controlled fake microphone audio occupies this recording interval; it is not a readiness wait.
await page.waitForTimeout(300)
await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).click()
await expect.poll(() => input.innerText()).toBe(prompt)
expect(recognize).toHaveBeenCalledOnce()
expect(messages).toBe(0)
await compareOrRefreshGolden(expected, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), webSnapshotMode())
const settled = scaffold.whenTurnSettled()
await input.press('Enter')
await settled
await page.getByText('DONE', { exact: true }).waitFor()
const meter = page.getByRole('button', { name: /% of context used/ })
await meter.waitFor()
for (const width of [1280, 420]) {
await page.setViewportSize({ width, height: 900 })
await expect.poll(async () => {
const micRect = await mic.boundingBox(), meterRect = await meter.boundingBox()
return micRect !== null && meterRect !== null && meterRect.y >= micRect.y + micRect.height
}).toBe(true)
await meter.click()
await page.getByRole('dialog', { name: 'of context used', exact: true }).waitFor()
await page.keyboard.press('Escape')
}
await mic.hover()
await page.getByRole('tooltip', { name: 'Dictate', exact: true }).waitFor()
const controlBackground = await page.getByRole('button', { name: 'Add files or run commands', exact: true })
.evaluate(element => getComputedStyle(element).backgroundColor)
await mic.click()
const stop = page.getByRole('button', { name: 'Stop and transcribe', exact: true })
await stop.waitFor()
for (const width of [1280, 420]) {
await page.setViewportSize({ width, height: 900 })
const waveformBox = await page.getByRole('img', { name: 'Recording…', exact: true }).boundingBox()
const stopBox = await stop.boundingBox(), cancelBox = await page.getByRole('button', { name: 'Cancel', exact: true }).boundingBox()
expect(waveformBox!.height).toBeLessThanOrEqual(Math.min(stopBox!.height, cancelBox!.height))
}
await input.hover()
expect(await meter.isVisible()).toBe(false)
expect(await stop.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(controlBackground)
expect(await page.getByRole('button', { name: 'Cancel', exact: true })
.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(controlBackground)
await page.keyboard.press('Escape')
await mic.waitFor()
await meter.waitFor()
await meter.click()
await page.getByRole('dialog', { name: 'of context used', exact: true }).waitFor()
await page.keyboard.press('Escape')
await mic.click()
await stop.waitFor()
await page.evaluate(() => {
const recorder = (window as Window & { voiceTestRecorder?: MediaRecorder }).voiceTestRecorder
if (!recorder) throw new Error('No active test microphone')
recorder.dispatchEvent(new Event('error'))
})
await page.getByRole('button', { name: 'Record again', exact: true }).waitFor()
expect(await stop.count()).toBe(0)
await compareOrRefreshGolden(interruptedExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
expect(recognize).toHaveBeenCalledOnce()
await page.getByRole('button', { name: 'Record again', exact: true }).click()
await stop.waitFor()
await page.keyboard.press('Escape')
expect(messages).toBe(1)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
+44
View File
@@ -0,0 +1,44 @@
/** Empty local caches guide explicit enablement to plugin setup without starting installation. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
const expected = fileURLToPath(new URL('./expected/voice-setup.expected.md', import.meta.url))
it('guides a newly enabled voice plugin to installation and lets the user postpone it', async () => {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
})
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: false }] } })
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
const toggle = page.getByRole('switch', { name: 'Enable Voice input', exact: true })
await toggle.waitFor()
expect(await toggle.getAttribute('aria-checked')).toBe('false')
const dialog = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true })
expect(await dialog.count()).toBe(0)
await toggle.click()
await dialog.waitFor()
await compareOrRefreshGolden(expected, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
await dialog.getByRole('button', { name: 'Later', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
expect(await card.getByRole('status').count()).toBe(0)
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await toggle.click()
await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click()
await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
expect(await page.getByText('Local models will be downloaded to the machine running DSH. No Python or compiler is required.', { exact: true }).count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
})
+2
View File
@@ -25,6 +25,8 @@
"tests/default-product-isolation.e2e.ts",
"tests/diff-context.e2e.ts",
"tests/scaffold.ts",
"tests/voice-input.e2e.ts",
"tests/voice-setup.e2e.ts",
"tests/prefix-proxy.ts",
"tests/auto-review-fixture.ts",
"tests/scaffold-generation.spec.ts",
+2 -1
View File
@@ -33,7 +33,8 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/capability-seams.md
capability-seams.md: 7deaab6d82c409b72a86bfc5a96a7bf2885aedc8
capability-seams.zh.md: 0d38dc1f31a39c1160eeef5bac205dcc325631e6
capability-seams.md: f783b8333c1e081f9af9a4abfb310cb253ce04eb
capability-seams.zh.md: 5fec8dfbd7a393b2970e51d1d0c7df5876b1d77e
+11
View File
@@ -64,6 +64,8 @@ flowchart LR
pkg_subagent["subagent"]
pkg_invariants["invariants"]
pkg_message_feedback["message-feedback"]
pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"]
svc_speechController["ctx.speechController<br/>Experimental transcription Remote"]
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences<br/>Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog<br/>Session-addressed skill Remote adapter"]
@@ -214,6 +216,9 @@ flowchart LR
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_tool_subagent_control["tool-subagent-control"]
pkg_tool_ralph["tool-ralph"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
svc_speechToText["ctx.speechToText<br/>Experimental speech recognition providers"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_agent_team["experimental-agent-team"]
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -292,12 +297,15 @@ flowchart LR
pkg_credentials_local --> svc_credentials
pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
pkg_experimental_agent_team --> svc_agentTeams
pkg_experimental_api_speech_to_text --> svc_speechController
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
pkg_file_reference --> svc_fileReferences
pkg_file_reference_local --> svc_fileReferences
pkg_fs --> svc_fs
@@ -488,6 +496,7 @@ flowchart LR
svc_shellEnv --> pkg_tool_bash
svc_shellEnv --> pkg_tool_pwsh
svc_skills --> pkg_tool_skill
svc_speechToText --> pkg_experimental_api_speech_to_text
svc_spillStore --> pkg_spill_policy
svc_ssh --> pkg_fs_ssh
svc_ssh --> pkg_sandbox_ssh
@@ -555,6 +564,7 @@ flowchart LR
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
| `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent`](../packages/subagent/subagent), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.speechController` | `core` | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | - | - | Validates bounded browser audio before provider dispatch. |
| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. |
| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. |
| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. |
@@ -610,6 +620,7 @@ flowchart LR
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
+11
View File
@@ -66,6 +66,8 @@ flowchart LR
pkg_subagent["subagent"]
pkg_invariants["invariants"]
pkg_message_feedback["message-feedback"]
pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"]
svc_speechController["ctx.speechController<br/>Experimental transcription Remote"]
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences<br/>Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog<br/>Session-addressed skill Remote adapter"]
@@ -216,6 +218,9 @@ flowchart LR
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_tool_subagent_control["tool-subagent-control"]
pkg_tool_ralph["tool-ralph"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
svc_speechToText["ctx.speechToText<br/>Experimental speech recognition providers"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_agent_team["experimental-agent-team"]
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -294,12 +299,15 @@ flowchart LR
pkg_credentials_local --> svc_credentials
pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
pkg_experimental_agent_team --> svc_agentTeams
pkg_experimental_api_speech_to_text --> svc_speechController
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
pkg_file_reference --> svc_fileReferences
pkg_file_reference_local --> svc_fileReferences
pkg_fs --> svc_fs
@@ -490,6 +498,7 @@ flowchart LR
svc_shellEnv --> pkg_tool_bash
svc_shellEnv --> pkg_tool_pwsh
svc_skills --> pkg_tool_skill
svc_speechToText --> pkg_experimental_api_speech_to_text
svc_spillStore --> pkg_spill_policy
svc_ssh --> pkg_fs_ssh
svc_ssh --> pkg_sandbox_ssh
@@ -557,6 +566,7 @@ flowchart LR
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
| `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent`](../packages/subagent/subagent), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 |
| `ctx.speechController` | `core` | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | - | - | 在 Provider 调用前校验受限的浏览器音频。 |
| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 |
| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 |
| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 |
@@ -612,6 +622,7 @@ flowchart LR
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 |
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方准备本地子代理或执行外部任务;统一的 activation manager 管理两种生命周期。委派工具选择后端,控制工具向本地子代理投递后续消息,工作流等待 activation 结果。 |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: fa99628cbe6b1c5d7b46a5b142da1e8ead419c0e
config-catalog.zh.md: c6177c03218f0999ec323f31da8feb752ecd2d14
config-catalog.md: aaaa20e37084f2705f351ab912500f841b4887d1
config-catalog.zh.md: 0b72df1fc0de1ab3f673a6167e9402308d89ffd9
+99
View File
@@ -502,6 +502,15 @@ export interface Config {
/** Maximum readers including source and renderer metadata lookups. */
maxReaders: number
}
/** Browser spreadsheet parser and dense cell allocation limits. */
excel: {
/** Maximum source file bytes. */
maxBytes: number
/** Maximum combined rectangular cell area across worksheets. */
maxCells: number
/** Maximum parser Worker lifetime in milliseconds. */
timeoutMs: number
}
}
```
@@ -635,6 +644,24 @@ export interface Config {
Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts)
<a id="deepseek-aidsh-experimental-api-speech-to-text"></a>
## `@deepseek-ai/dsh-experimental-api-speech-to-text`
Requires: `speechToText` · `typert`
```ts config-catalog
/** Limits applied before decoding or calling a provider. */
export interface Config {
/** Maximum decoded WAV bytes per request. */
maxAudioBytes: number
/** Maximum PCM recording duration in seconds. */
maxDurationSeconds: number
}
```
Source: [`packages/experimental/api-speech-to-text/src/index.ts:20`](../packages/experimental/api-speech-to-text/src/index.ts)
<a id="deepseek-aidsh-experimental-browser-use-chrome-devtools-mcp"></a>
## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp`
@@ -865,6 +892,76 @@ export interface Config {
Source: [`packages/experimental/ptc-runtime-python/src/index.ts:42`](../packages/experimental/ptc-runtime-python/src/index.ts)
<a id="deepseek-aidsh-experimental-speech-to-text"></a>
## `@deepseek-ai/dsh-experimental-speech-to-text`
```ts config-catalog
/** Composition defaults resolved before a transcription starts. */
export interface Config {
/** Registered provider selected when the caller omits an id. */
defaultProvider: string
/** Provider language hint selected when the caller omits one. */
language: string
}
```
Source: [`packages/experimental/speech-to-text/src/index.ts:18`](../packages/experimental/speech-to-text/src/index.ts)
<a id="deepseek-aidsh-experimental-speech-to-text-sensevoice"></a>
## `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice`
Requires: `speechToText` · `subprocess`
```ts config-catalog
/** Local runtime, inference, and retention settings. */
export interface Config {
/** Unique registration id; consumers select this exact id. */
providerId: string
/** Absolute directory for verified ONNX models. */
dataRoot: string
/** Existing directory containing the selected ONNX model and tokens.txt; omission downloads verified files. */
modelDirectory?: string | undefined
/** Existing Silero VAD ONNX file; omission downloads the verified model. */
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
segmentSeconds: number
/** Silero speech probability threshold. */
vadThreshold: number
/** Minimum speech duration retained by VAD. */
minSpeechSeconds: number
/** Silence separating two speech segments. */
minSilenceSeconds: number
/** Maximum decoded WAV bytes accepted by the private worker. */
maxAudioBytes: number
/** Deadline for runtime preparation and cold model loading. */
prepareTimeoutMs: number
/** Deadline for one inference after the worker is ready. */
inferenceTimeoutMs: number
/** Idle period before stopping the worker; zero keeps it warm. */
idleTimeoutMs: number
/** Maximum accepted running and waiting transcriptions. */
maxPending: number
/** Managed process termination grace period. */
graceMs: number
/** Maximum retained worker diagnostic bytes. */
maxLogBytes: number
/** Maximum transcript response bytes. */
maxResponseBytes: number
/** Minimum interval between intermediate download progress notifications. */
progressIntervalMs: number
}
```
Source: [`packages/experimental/speech-to-text-sensevoice/src/config.ts:6`](../packages/experimental/speech-to-text-sensevoice/src/config.ts)
<a id="deepseek-aidsh-experimental-tool-agent-team"></a>
## `@deepseek-ai/dsh-experimental-tool-agent-team`
@@ -3895,6 +3992,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@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-experimental-auto-review` — requires `llm` · `permissionPresets` · `sessions` · `tools` ([`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-voice-input` ([`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts))
- `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` — requires `computerUse` · `tools` · `systemPrompt` ([`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts))
- `@deepseek-ai/dsh-fs-observation-policy` ([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts))
- `@deepseek-ai/dsh-fs-ssh` — requires `ssh` · `sandboxPolicy` ([`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts))
@@ -3967,6 +4065,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-deque` ([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts))
- `@deepseek-ai/dsh-experimental-voice-input-bundle` ([`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
- `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
+99
View File
@@ -506,6 +506,15 @@ export interface Config {
/** Maximum readers including source and renderer metadata lookups. */
maxReaders: number
}
/** Browser spreadsheet parser and dense cell allocation limits. */
excel: {
/** Maximum source file bytes. */
maxBytes: number
/** Maximum combined rectangular cell area across worksheets. */
maxCells: number
/** Maximum parser Worker lifetime in milliseconds. */
timeoutMs: number
}
}
```
@@ -639,6 +648,24 @@ export interface Config {
来源:[`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts)
<a id="deepseek-aidsh-experimental-api-speech-to-text"></a>
## `@deepseek-ai/dsh-experimental-api-speech-to-text`
需要: `speechToText` · `typert`
```ts config-catalog
/** Limits applied before decoding or calling a provider. */
export interface Config {
/** Maximum decoded WAV bytes per request. */
maxAudioBytes: number
/** Maximum PCM recording duration in seconds. */
maxDurationSeconds: number
}
```
来源: [`packages/experimental/api-speech-to-text/src/index.ts:20`](../packages/experimental/api-speech-to-text/src/index.ts)
<a id="deepseek-aidsh-experimental-browser-use-chrome-devtools-mcp"></a>
## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp`
@@ -869,6 +896,76 @@ export interface Config {
来源:[`packages/experimental/ptc-runtime-python/src/index.ts:42`](../packages/experimental/ptc-runtime-python/src/index.ts)
<a id="deepseek-aidsh-experimental-speech-to-text"></a>
## `@deepseek-ai/dsh-experimental-speech-to-text`
```ts config-catalog
/** Composition defaults resolved before a transcription starts. */
export interface Config {
/** Registered provider selected when the caller omits an id. */
defaultProvider: string
/** Provider language hint selected when the caller omits one. */
language: string
}
```
来源: [`packages/experimental/speech-to-text/src/index.ts:18`](../packages/experimental/speech-to-text/src/index.ts)
<a id="deepseek-aidsh-experimental-speech-to-text-sensevoice"></a>
## `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice`
需要: `speechToText` · `subprocess`
```ts config-catalog
/** Local runtime, inference, and retention settings. */
export interface Config {
/** Unique registration id; consumers select this exact id. */
providerId: string
/** Absolute directory for verified ONNX models. */
dataRoot: string
/** Existing directory containing the selected ONNX model and tokens.txt; omission downloads verified files. */
modelDirectory?: string | undefined
/** Existing Silero VAD ONNX file; omission downloads the verified model. */
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
segmentSeconds: number
/** Silero speech probability threshold. */
vadThreshold: number
/** Minimum speech duration retained by VAD. */
minSpeechSeconds: number
/** Silence separating two speech segments. */
minSilenceSeconds: number
/** Maximum decoded WAV bytes accepted by the private worker. */
maxAudioBytes: number
/** Deadline for runtime preparation and cold model loading. */
prepareTimeoutMs: number
/** Deadline for one inference after the worker is ready. */
inferenceTimeoutMs: number
/** Idle period before stopping the worker; zero keeps it warm. */
idleTimeoutMs: number
/** Maximum accepted running and waiting transcriptions. */
maxPending: number
/** Managed process termination grace period. */
graceMs: number
/** Maximum retained worker diagnostic bytes. */
maxLogBytes: number
/** Maximum transcript response bytes. */
maxResponseBytes: number
/** Minimum interval between intermediate download progress notifications. */
progressIntervalMs: number
}
```
来源: [`packages/experimental/speech-to-text-sensevoice/src/config.ts:6`](../packages/experimental/speech-to-text-sensevoice/src/config.ts)
<a id="deepseek-aidsh-experimental-tool-agent-team"></a>
## `@deepseek-ai/dsh-experimental-tool-agent-team`
@@ -3899,6 +3996,7 @@ export interface Config {
- `@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-experimental-auto-review` — 需要 `llm` · `permissionPresets` · `sessions` · `tools`([`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-voice-input` ([`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts))
- `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` — requires `computerUse` · `tools` · `systemPrompt` ([`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts))
- `@deepseek-ai/dsh-fs-observation-policy`([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts))
- `@deepseek-ai/dsh-fs-ssh` — 需要 `ssh` · `sandboxPolicy`([`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts))
@@ -3970,6 +4068,7 @@ export interface Config {
- `@deepseek-ai/dsh-deque`([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts))
- `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts))
- `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts))
- `@deepseek-ai/dsh-experimental-voice-input-bundle`([`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
- `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
- `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: 39a54fa5a8c213a999aa5e10fdd373dcfcb566c0
module-graph.zh.md: 268d9e47696c1a7a47590d79a6026d8e7b706491
module-graph.md: 65bcbfdc580aeb92e1bee1b4864b41fdf412fd85
module-graph.zh.md: 6a2bcc92d3fbcb7596a6deac4465c04ca6c50038
+30
View File
@@ -231,17 +231,22 @@ flowchart TD
subgraph group_experimental["packages/experimental"]
pkg_experimental_agent_team["experimental-agent-team"]
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"]
pkg_experimental_auto_review["experimental-auto-review"]
pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"]
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"]
pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"]
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
pkg_experimental_voice_input_bundle["experimental-voice-input-bundle"]
pkg_experimental_webworker_packer["experimental-webworker-packer"]
pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
end
@@ -507,6 +512,7 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -602,9 +608,13 @@ flowchart TD
pkg_tmux_context --> pkg_session
pkg_tmux_context --> pkg_session_projection
pkg_tmux_context --> pkg_shell
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
pkg_experimental_ptc_runtime_python --> pkg_timeout
pkg_experimental_ptc_runtime_python --> pkg_util_values
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
pkg_commands --> pkg_agent
pkg_commands --> pkg_attachment
pkg_commands --> pkg_brand
@@ -1251,6 +1261,21 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session
pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
pkg_experimental_client_ui_voice_input --> pkg_client_locale
pkg_experimental_client_ui_voice_input --> pkg_client_store
pkg_experimental_client_ui_voice_input --> pkg_client_ui_conversation
pkg_experimental_client_ui_voice_input --> pkg_client_ui_plugin_manager
pkg_experimental_client_ui_voice_input --> pkg_client_ui_primitives
pkg_experimental_client_ui_voice_input --> pkg_client_ui_renderer
pkg_experimental_client_ui_voice_input --> pkg_client_ui_session
pkg_experimental_client_ui_voice_input --> pkg_client_ui_slots
pkg_experimental_client_ui_voice_input --> pkg_experimental_api_speech_to_text
pkg_experimental_client_ui_voice_input --> pkg_experimental_speech_to_text
pkg_experimental_client_ui_voice_input --> pkg_session
pkg_experimental_client_ui_voice_input --> pkg_typert_protocol
pkg_experimental_tool_agent_team --> pkg_agent
pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
pkg_experimental_tool_agent_team --> pkg_session
@@ -1375,6 +1400,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — |
@@ -1447,6 +1473,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1470,7 +1497,9 @@ flowchart TD
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
| [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1582,6 +1611,7 @@ flowchart TD
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`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) |
| [`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) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`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) |
| [`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`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
+30
View File
@@ -233,17 +233,22 @@ flowchart TD
subgraph group_experimental["packages/experimental"]
pkg_experimental_agent_team["experimental-agent-team"]
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"]
pkg_experimental_auto_review["experimental-auto-review"]
pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"]
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"]
pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"]
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
pkg_experimental_voice_input_bundle["experimental-voice-input-bundle"]
pkg_experimental_webworker_packer["experimental-webworker-packer"]
pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
end
@@ -509,6 +514,7 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -604,9 +610,13 @@ flowchart TD
pkg_tmux_context --> pkg_session
pkg_tmux_context --> pkg_session_projection
pkg_tmux_context --> pkg_shell
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
pkg_experimental_ptc_runtime_python --> pkg_timeout
pkg_experimental_ptc_runtime_python --> pkg_util_values
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
pkg_commands --> pkg_agent
pkg_commands --> pkg_attachment
pkg_commands --> pkg_brand
@@ -1253,6 +1263,21 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session
pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
pkg_experimental_client_ui_voice_input --> pkg_client_locale
pkg_experimental_client_ui_voice_input --> pkg_client_store
pkg_experimental_client_ui_voice_input --> pkg_client_ui_conversation
pkg_experimental_client_ui_voice_input --> pkg_client_ui_plugin_manager
pkg_experimental_client_ui_voice_input --> pkg_client_ui_primitives
pkg_experimental_client_ui_voice_input --> pkg_client_ui_renderer
pkg_experimental_client_ui_voice_input --> pkg_client_ui_session
pkg_experimental_client_ui_voice_input --> pkg_client_ui_slots
pkg_experimental_client_ui_voice_input --> pkg_experimental_api_speech_to_text
pkg_experimental_client_ui_voice_input --> pkg_experimental_speech_to_text
pkg_experimental_client_ui_voice_input --> pkg_session
pkg_experimental_client_ui_voice_input --> pkg_typert_protocol
pkg_experimental_tool_agent_team --> pkg_agent
pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
pkg_experimental_tool_agent_team --> pkg_session
@@ -1377,6 +1402,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — |
@@ -1449,6 +1475,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1472,7 +1499,9 @@ flowchart TD
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
| [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1584,6 +1613,7 @@ flowchart TD
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`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) |
| [`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) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`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) |
| [`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`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 3669223a19d9a99689987ed13663e9f5395a674b
persistence-catalog.zh.md: 6e5ff33e247aa58060c2b11edfe8551a8021db3b
persistence-catalog.md: e8e94f4cf85d3e7b2da7fc2f3ba1da2184b0ccf5
persistence-catalog.zh.md: 00ff96ef9f571d679f8771b082c413b1d8893274
+8 -8
View File
@@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/
Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagedelivered--log-only"></a>
@@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagequeued--log-only"></a>
@@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
<a id="teamtask--log-only"></a>
@@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -7396,7 +7396,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
Sources: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7424,7 +7424,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
Sources: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7438,7 +7438,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:225`](../packages/exper
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
Sources: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7584,7 +7584,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](.
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
Sources: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+8 -8
View File
@@ -977,7 +977,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagedelivered--log-only"></a>
@@ -995,7 +995,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagequeued--log-only"></a>
@@ -1008,7 +1008,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
<a id="teamtask--log-only"></a>
@@ -1021,7 +1021,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -7398,7 +7398,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731`
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
来源:[`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7426,7 +7426,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5`
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
来源:[`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7440,7 +7440,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
来源:[`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7586,7 +7586,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028`
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
来源:[`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+4 -4
View File
@@ -22615,7 +22615,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:227"
"packages/experimental/agent-team/src/types.ts:216"
]
},
{
@@ -69054,7 +69054,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:223"
"packages/experimental/agent-team/src/types.ts:212"
]
},
{
@@ -71036,7 +71036,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:221"
"packages/experimental/agent-team/src/types.ts:210"
]
},
{
@@ -83523,7 +83523,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:225"
"packages/experimental/agent-team/src/types.ts:214"
]
},
{
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
README.md: d54bd4f45c23100cd1d53080f1ca807a9c177d3d
README.zh.md: 81ef25b69547672e016a2b72e2293daa8bb42eb1
README.md: db21e0fe6bea366c4d7efc1d96d247dd255f54da
README.zh.md: 4b2b28dbf9a39da0c0ce3a062c4de3261501f7d9
+1
View File
@@ -46,6 +46,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
| [compaction.md](compaction.md) | the compaction seam: the `compaction/*` session events, `CompactionResult`, the `CompactionEngine` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion |
| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, durable peer mailbox, and shared task DAG |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
+1
View File
@@ -46,6 +46,7 @@
| [skills.md](skills.zh.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 |
| [compaction.md](compaction.zh.md) | 压缩(compaction)seam:`compaction/*` 会话事件、`CompactionResult`、`CompactionEngine` 接口 |
| [subagent.md](subagent.zh.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 |
| [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 |
| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、持久 peer mailbox 与共享任务 DAG |
| [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` |
| [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md
agent-team.md: 653bbbcad50d723690c013161a0c25eea301ff0b
agent-team.zh.md: e9ef2d5dc824a81d1c666d0ea7a270c0cfe2c92e
agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba
agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810
-16
View File
@@ -183,22 +183,6 @@ tryMembership(agent: Agent): TeamMembership | undefined
* @returns detached current roster and task views.
*/
@Remote('view') remoteView(agent: Agent): TeamView
/**
* Create one shared task through the generated Remote API.
* @param agent - exact live Team member creating the task.
* @param request - task text, blockers, and advisory write scopes.
* @returns the revision-one task or a typed Team rejection.
*/
@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise<TeamTaskMutationResult>
/**
* Apply one task mutation and preserve Team rejections as business results.
* @param agent - exact live Team member authorizing the mutation.
* @param request - task identity, expected revision, action, and action fields.
* @returns the committed task or a typed Team rejection.
*/
@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise<TeamTaskMutationResult>
```
Types: [Agent](core.md)
-16
View File
@@ -183,22 +183,6 @@ tryMembership(agent: Agent): TeamMembership | undefined
* @returns detached current roster and task views.
*/
@Remote('view') remoteView(agent: Agent): TeamView
/**
* Create one shared task through the generated Remote API.
* @param agent - exact live Team member creating the task.
* @param request - task text, blockers, and advisory write scopes.
* @returns the revision-one task or a typed Team rejection.
*/
@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise<TeamTaskMutationResult>
/**
* Apply one task mutation and preserve Team rejections as business results.
* @param agent - exact live Team member authorizing the mutation.
* @param request - task identity, expected revision, action, and action fields.
* @returns the committed task or a typed Team rejection.
*/
@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise<TeamTaskMutationResult>
```
Types: [Agent](core.zh.md)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098
conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3
conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310
conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746
+27
View File
@@ -16,6 +16,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. |
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. |
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence.
@@ -26,6 +27,32 @@ Each Session keeps a monotonic set of active targets. Creating or reading a targ
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity.
<a id="group-definitions"></a>
## Group Definitions
The optional `ctx.uiConversation.groups.register(definition)` contribution uses the same effect-owned registry lifecycle as event and view registrations. It requires an existing View target and rejects duplicate target registrations without constructing a Builder. Each Session creates its own Group context only when that target activates. Registration replacement discards the old context and clears its observed groups during the existing registry rebuild.
The [group types](../../packages/client/ui-conversation/src/client/contract/groups.ts) define the complete protocol:
| Type | Meaning |
|---|---|
| `ConversationGroupDefinition<Node, State, Data>` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. |
| `ConversationGroupInput<Node>` | `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers. `apply` adds projected `previous/current` Node changes, lifecycle `changedTurns`, and `changedTurnOrders`. Do not retain readers in State. |
| `GroupNodePosition` | Owning Turn, when present, and immediate previous/next visible Node keys. Neighbours preserve interruptions that a Turn-only key list omits. |
| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. |
| `GroupSnapshot<Data>` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. |
| `GroupUpdate<Data>` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. |
| `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. |
| `ConversationGroupedView<Data>` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. |
The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence.
Target position indexes supply the readers and identify Turns whose visible keys or neighbours changed. `changedTurnOrders` includes both owners of a moved Node and Turns adjacent to inserted or removed unscoped Nodes; lifecycle-only changes remain in `changedTurns`. A business Definition can resegment those Turns and refresh only groups containing changed content. Structural output still replaces the complete root-reference array, without requiring unchanged Node contents to be reread.
The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference.
Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies.
## Replayable event families
Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context.
+27
View File
@@ -16,6 +16,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 |
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 |
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。
@@ -26,6 +27,32 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。
<a id="group-definitions"></a>
## Group Definition
可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。
[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议:
| 类型 | 含义 |
|---|---|
| `ConversationGroupDefinition<Node, State, Data>` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 |
| `ConversationGroupInput<Node>` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 |
| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 |
| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 |
| `GroupSnapshot<Data>` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 |
| `GroupUpdate<Data>` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 |
| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 |
| `ConversationGroupedView<Data>` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 |
注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。
目标位置索引提供这些读取器,并标识可见键或相邻关系发生变化的 Turn。`changedTurnOrders` 包含移动 Node 的新旧所属轮次,以及相邻的轮次外 Node 被插入或移除所影响的轮次;仅生命周期变化仍由 `changedTurns` 提供。业务 Definition 可只重分这些轮次,并只刷新包含变化内容的组。结构输出仍替换完整根引用数组,但不要求重读未变化的 Node 内容。
Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。
渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。
## 可回放 event family
编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
sidebar-right.md: bade9a958a62368b84ddfae8b5dbe869dfe11e4a
sidebar-right.zh.md: 93a6f1f25171b19fef5876e353e4b8be46aef78d
sidebar-right.md: 7ec6d931ef92a23cf5cbd647d97e03fb55c32f84
sidebar-right.zh.md: 413ea2145de19738e939f24778da0765c7aa5424
+1 -1
View File
@@ -116,7 +116,7 @@ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/docum
Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body invokes its injected face, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its face writes converted bytes, font metadata, and failures to its own store, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
## Resource model
+1 -1
View File
@@ -116,7 +116,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed
Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文调用自己的注入 face,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);其 face 将转换字节、字体元数据和失败写入自己的 store,正文渲染嵌套 PDF 视图。`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
## 资源模型
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/voice-input.md
voice-input.md: f1d3f36d067f756235e976fa6eafe70f94b19ccc
voice-input.zh.md: a32b539fc1237bb70d68668de939084d5f634a82
+158
View File
@@ -0,0 +1,158 @@
# Voice input
English | [中文](voice-input.zh.md)
Experimental speech recognition has three roles: the [Service Definition](../../packages/experimental/speech-to-text/README.md) routes named providers, the [SenseVoice provider](../../packages/experimental/speech-to-text-sensevoice/README.md) owns local inference, and the [Remote consumer](../../packages/experimental/api-speech-to-text/README.md) serves the browser. The [optional bundle](../../packages/experimental/voice-input-bundle/README.md) composes them with the microphone UI.
## Provider selection
`SpeechProviderId` brands the registration identity. `SpeechProviderInfo` carries a display name, accepted `languages` hints and `host-local` or `cloud` processing location. `SpeechRequest` contains WAV bytes and optional provider/language selection; `resolve()` produces `SpeechSpec` with one captured provider instance. A missing provider or unsupported language fails; replacement or withdrawal invalidates resolved work. Audio never falls back to a different provider.
`SpeechProvider.transcribe()` accepts one complete `SpeechInput` and an `AbortSignal`. `Transcript` returns plain `text`, `audioSeconds` and `inferenceSeconds`. Providers honor cancellation and settle their work before deregistration completes. The local provider serializes calls, bounds its queue and manages its worker lifetime independently of Sessions.
## Browser and Host ownership
The browser owns microphone tracks and the unsent draft. `TranscriptionRequest` sends canonical base64 PCM16 WAV through the authenticated Remote. `SpeechCatalog` advertises available providers, the default selection and recording byte/duration limits. The API validates this process input before recognition.
The input facade captures a revision-bearing selection before recording. `InputActions.insertText()` inserts one undoable plain-text edit only while the selection revision is current and submission permits editing. A rejected insertion leaves the transcript available for explicit insertion. Switching Sessions or disposing the plugin invalidates late results. Recognition itself writes no Session event; ordinary user submission owns the final model-visible text.
## Preparation and settings
The Host provider owns one preparation task across page and Session changes. The Client shares one `follow()` subscription across the composer, setup prompt and bundle details. Optional `SpeechSetupEstimate` metadata supplies provider-specific planning hints, separate from measured progress. `SpeechPreparationStepKind` identifies ordered resource operations; `SpeechPreparationStep` records each status and start time. The complete `SpeechPreparationState` retains these steps after cancellation or failure. The collapsed UI shows the current operation; expanding lists all steps, with byte progress or elapsed time only for the running step. Closing an observer never cancels preparation. Verified files survive retries and idle worker reclamation.
The microphone occupies `conversation.input.activity`, between the model selector and Send. Clicking starts capture and expands the toolbar; Stop transcribes and inserts into the draft. The activity preserves the editor and submit action, owns local feedback, and releases expansion on unmount. Cancel, Escape or hiding the page discards capture. Waveform history displays measured microphone amplitude. Bundle details contain recognition preferences, preparation state and progress. Explicit enablement uses `plugins.bundle.activation` to guide users with missing models to setup; the list shows only the bundle description and switch.
## Design rationale
The [voice input decision](../../.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md) explains transient audio, explicit provider selection and lazy local preparation.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxspeechcontroller--speechcontroller"></a>
### `ctx.speechController` — `SpeechController`
Speech calls never activate or submit to an Agent.
```ts cordis-catalog
/**
* Read provider choices without preparing a recognizer.
* @returns available providers, resolved default, and recording limits.
*/
@Remote catalog(): SpeechCatalog
/**
* Follow provider readiness independently of Session and preparation lifetimes.
* @param signal - Client observation lifetime.
* @returns initial and subsequent complete readiness snapshots.
*/
@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable<SpeechCatalog>
/**
* Persist the user's recognition preferences.
* @param patch - changed preference fields.
* @returns after preferences are saved.
*/
@Remote configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
*/
@Remote prepare(providerId: SpeechProviderId): void
/**
* Explicitly cancel resource preparation.
* @param providerId - selected recognizer.
* @returns after the preparation task settles.
*/
@Remote cancelPreparation(providerId: SpeechProviderId): Promise<void>
/**
* Validate and transcribe one recording through the explicit provider selection.
* @param request - canonical WAV encoded as base64, provider id and language hint.
* @param signal - Client cancellation or Remote contribution disposal.
* @returns final transcript without adding a Session event.
*/
@Remote async transcribe(request: TranscriptionRequest, signal: AbortSignal): Promise<Transcript>
```
Source: [`packages/experimental/api-speech-to-text/src/index.ts`](../../packages/experimental/api-speech-to-text/src/index.ts)
<a id="ctxspeechtotext--speechtotext"></a>
### `ctx.speechToText` — `SpeechToText`
Registry shared by all transcription consumers in one Host composition.
```ts cordis-catalog
/**
* Register one recognizer; duplicate ids fail without replacing the original.
* @param provider - recognizer owned by the contributing fiber.
* @returns idempotent disposer which rejects admission, cancels, and joins accepted work.
*/
register(provider: SpeechProvider): () => Promise<void>
/**
* Read the current recognizer roster.
* @returns available provider facts in registration order.
*/
listProviders(): readonly SpeechProviderInfo[]
/**
* Observe complete readiness snapshots; a slow reader coalesces intermediate progress.
* @param caller - observer lifetime, independent of any preparation task.
* @returns an initial snapshot followed by the latest provider states.
*/
async *follow(caller: AbortSignal): AsyncIterable<SpeechSnapshot>
/**
* Read provider readiness and current user preferences together.
* @returns one detached complete observation.
*/
snapshot(): SpeechSnapshot
/**
* Persist changed preference fields; the resulting language must be accepted by the selected provider.
* @param patch - explicit provider or language changes.
* @returns after persistence and the resolved preference update.
*/
async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
*/
prepare(id: SpeechProviderId): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
* @param id - exact registered provider identity.
* @returns after the preparation task settles.
*/
async cancelPreparation(id: SpeechProviderId): Promise<void>
/**
* Apply composition defaults and capture the selected provider. Missing providers and unsupported languages fail explicitly.
* @param request - complete recording and optional selection.
* @returns provider-pinned input for transcribe().
*/
resolve(request: SpeechRequest): SpeechSpec
/**
* Execute exactly the resolved provider; no fallback sends audio elsewhere.
* @param spec - resolved input; a withdrawn or replaced registration is rejected.
* @param signal - caller cancellation.
* @returns final transcript after provider settlement.
*/
async transcribe(spec: SpeechSpec, signal: AbortSignal): Promise<Transcript>
```
Source: [`packages/experimental/speech-to-text/src/index.ts`](../../packages/experimental/speech-to-text/src/index.ts)
<!-- END GENERATED cordis-surface -->
+158
View File
@@ -0,0 +1,158 @@
# 语音输入
[English](voice-input.md) | 中文
实验性语音识别包含三个角色:[服务定义](../../packages/experimental/speech-to-text/README.zh.md)路由具名 Provider,[SenseVoice Provider](../../packages/experimental/speech-to-text-sensevoice/README.zh.md)拥有本地推理,[Remote 消费者](../../packages/experimental/api-speech-to-text/README.zh.md)服务浏览器。[可选 Bundle](../../packages/experimental/voice-input-bundle/README.zh.md)将它们与麦克风 UI 组合。
## Provider 选择
`SpeechProviderId` 为注册标识添加类型品牌。`SpeechProviderInfo` 携带显示名称、支持的 `languages` 语言提示及 `host-local` 或 `cloud` 处理位置。`SpeechRequest` 包含 WAV 字节和可选的 Provider、语言选择;`resolve()` 生成捕获一个 Provider 实例的 `SpeechSpec`。缺失的 Provider 或不支持的语言会报错;替换或撤销会使已解析的任务失效。音频不会回退到其他 Provider。
`SpeechProvider.transcribe()` 接收完整的 `SpeechInput` 与 `AbortSignal`。`Transcript` 返回纯文本 `text`、`audioSeconds` 和 `inferenceSeconds`。Provider 响应取消,并在注销完成前结束其任务。本地 Provider 串行执行调用、限制队列,并独立于 Session 管理工作进程生命周期。
## 浏览器与 Host 所有权
浏览器拥有麦克风轨道与未发送的草稿。`TranscriptionRequest` 通过带认证的 Remote 发送规范 base64 PCM16 WAV。`SpeechCatalog` 公布可用 Provider、默认选择及录音字节和时长限制。API 在识别前校验这些进程输入。
输入门面在录音前捕获带版本的选区。`InputActions.insertText()` 仅在选区版本仍有效且提交状态允许编辑时,插入一次可撤销的纯文本编辑。插入被拒绝时,转写文字保留以供显式插入。切换 Session 或释放插件使迟到结果失效。识别本身不写入 Session 事件;普通用户提交拥有最终的模型可见文字。
## 准备与设置
Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、安装引导弹窗、Bundle 详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。
麦克风占用模型选择器与发送按钮之间的 `conversation.input.activity`。点击开始录音并展开工具栏;停止后转写并插入草稿。活动栏保留编辑器与提交按钮,拥有局部反馈,并在卸载时释放展开状态。取消、Escape 或隐藏页面会丢弃录音。波形历史展示实测麦克风音量。Bundle 详情包含识别偏好、准备状态和进度。显式启用时通过 `plugins.bundle.activation` 引导缺少模型的用户前往安装;列表只显示 Bundle 描述和开关。
## 设计依据
[语音输入决策](../../.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md)解释临时音频、显式 Provider 选择和延迟准备本地运行时。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxspeechcontroller--speechcontroller"></a>
### `ctx.speechController` — `SpeechController`
Speech calls never activate or submit to an Agent.
```ts cordis-catalog
/**
* Read provider choices without preparing a recognizer.
* @returns available providers, resolved default, and recording limits.
*/
@Remote catalog(): SpeechCatalog
/**
* Follow provider readiness independently of Session and preparation lifetimes.
* @param signal - Client observation lifetime.
* @returns initial and subsequent complete readiness snapshots.
*/
@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable<SpeechCatalog>
/**
* Persist the user's recognition preferences.
* @param patch - changed preference fields.
* @returns after preferences are saved.
*/
@Remote configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
*/
@Remote prepare(providerId: SpeechProviderId): void
/**
* Explicitly cancel resource preparation.
* @param providerId - selected recognizer.
* @returns after the preparation task settles.
*/
@Remote cancelPreparation(providerId: SpeechProviderId): Promise<void>
/**
* Validate and transcribe one recording through the explicit provider selection.
* @param request - canonical WAV encoded as base64, provider id and language hint.
* @param signal - Client cancellation or Remote contribution disposal.
* @returns final transcript without adding a Session event.
*/
@Remote async transcribe(request: TranscriptionRequest, signal: AbortSignal): Promise<Transcript>
```
Source: [`packages/experimental/api-speech-to-text/src/index.ts`](../../packages/experimental/api-speech-to-text/src/index.ts)
<a id="ctxspeechtotext--speechtotext"></a>
### `ctx.speechToText` — `SpeechToText`
Registry shared by all transcription consumers in one Host composition.
```ts cordis-catalog
/**
* Register one recognizer; duplicate ids fail without replacing the original.
* @param provider - recognizer owned by the contributing fiber.
* @returns idempotent disposer which rejects admission, cancels, and joins accepted work.
*/
register(provider: SpeechProvider): () => Promise<void>
/**
* Read the current recognizer roster.
* @returns available provider facts in registration order.
*/
listProviders(): readonly SpeechProviderInfo[]
/**
* Observe complete readiness snapshots; a slow reader coalesces intermediate progress.
* @param caller - observer lifetime, independent of any preparation task.
* @returns an initial snapshot followed by the latest provider states.
*/
async *follow(caller: AbortSignal): AsyncIterable<SpeechSnapshot>
/**
* Read provider readiness and current user preferences together.
* @returns one detached complete observation.
*/
snapshot(): SpeechSnapshot
/**
* Persist changed preference fields; the resulting language must be accepted by the selected provider.
* @param patch - explicit provider or language changes.
* @returns after persistence and the resolved preference update.
*/
async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
*/
prepare(id: SpeechProviderId): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
* @param id - exact registered provider identity.
* @returns after the preparation task settles.
*/
async cancelPreparation(id: SpeechProviderId): Promise<void>
/**
* Apply composition defaults and capture the selected provider. Missing providers and unsupported languages fail explicitly.
* @param request - complete recording and optional selection.
* @returns provider-pinned input for transcribe().
*/
resolve(request: SpeechRequest): SpeechSpec
/**
* Execute exactly the resolved provider; no fallback sends audio elsewhere.
* @param spec - resolved input; a withdrawn or replaced registration is rejected.
* @param signal - caller cancellation.
* @returns final transcript after provider settlement.
*/
async transcribe(spec: SpeechSpec, signal: AbortSignal): Promise<Transcript>
```
Source: [`packages/experimental/speech-to-text/src/index.ts`](../../packages/experimental/speech-to-text/src/index.ts)
<!-- END GENERATED cordis-surface -->
+1
View File
@@ -186,6 +186,7 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas
* manager ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)).
*/
export const OPTIONAL_BUNDLES: readonly string[] = [
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-agent-team-profile',
]
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6
README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004
README.md: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c
README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6
+10
View File
@@ -20,6 +20,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
- [Turn token usage](#turn-token-usage)
- [Completed-turn footer](#completed-turn-footer)
- [Turn Process Folding](#turn-process-folding)
- [Grouped rendering](#grouped-rendering)
- [Scroll ownership](#scroll-ownership)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
@@ -85,6 +86,15 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s
-----
<a id="grouped-rendering"></a>
## Grouped rendering
Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for reading-position restoration; Turn navigation can still address the original Node key and land on its first visible part. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped.
The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition.
-----
<a id="scroll-ownership"></a>
## Scroll ownership
+10
View File
@@ -20,6 +20,7 @@ kind: "package-reference"
- [轮次 token 用量](#turn-token-usage)
- [已完成轮次的页脚](#completed-turn-footer)
- [轮次过程折叠](#turn-process-folding)
- [分组渲染](#grouped-rendering)
- [滚动归属](#scroll-ownership)
- [模型体验](#model-experience)
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
@@ -85,6 +86,15 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm
-----
<a id="grouped-rendering"></a>
## 分组渲染
Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。
组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。
-----
<a id="scroll-ownership"></a>
## 滚动归属
+1
View File
@@ -86,6 +86,7 @@
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^",
"@tanstack/react-virtual": "^3.14.9",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
@@ -156,11 +157,13 @@ export function apply(ctx: Context): void {
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
const session = binding.session
const chat = chatSource(binding)
const conversation = ctx.uiConversation.binding(binding)
return {
hooks: { presentation },
keyedHooks: {
chatNode: key => chat.getSnapshot().nodes.source(key),
chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey),
},
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
// Files open in the right Sidebar, not in a desktop application: the
@@ -0,0 +1,3 @@
.root {
display: contents;
}
@@ -0,0 +1,30 @@
/** Stable parent for one group's Node references, independent of display mode. */
import { memo, type ComponentProps } from 'react'
import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { chatRenderKey } from './render-entry.ts'
import css from './ChatGroupSeat.module.css'
type ChatGroupSeatProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
readonly groupKey: GroupKey
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
}
/** Render the group's members without moving them between React parents on mode changes. */
export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) {
const members = useChatGroup(groupKey, group => group?.members)
if (members === undefined) return null
return (
<div className={css.root} data-chat-group-key={groupKey}>
{members.map(member => (
<ChatNodeSeat
{...props}
key={chatRenderKey(member)}
nodeKey={member.key}
{...member.groupPart === undefined ? {} : { groupPart: member.groupPart }}
/>
))}
</div>
)
})
@@ -3,6 +3,7 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatNodeStore } from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { storedTurnProcessEntry } from '../stores.ts'
import { useSearchableHidden } from './searchable-hidden.ts'
@@ -10,6 +11,8 @@ import css from './ChatView.module.css'
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly nodeKey: string
/** A replaced Builder must rebind keyed hooks even when references and keys survive. */
readonly nodeStore: ChatNodeStore
readonly useChatNode: ChatViewSlotProps['useChatNode']
readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
readonly usePresentation: UsePresentation
@@ -39,7 +42,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
* only seats whose visibility actually changes.
*/
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, useChatNode, useChatNodeProcess, usePresentation,
nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
}: ChatNodeSeatProps) {
@@ -108,6 +111,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
? null
: {
...groupPart === undefined ? {} : { groupPart },
cwd,
openFile,
openSkill,
@@ -118,7 +122,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
fileMentions,
turnProcess,
}, [
node, cwd, openFile, openSkill, inspectCall, forkAt,
node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
@@ -127,12 +131,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
// keyed-slot entry passed alongside that same Node. TypeScript does not
// distribute an object containing a union into a union of objects itself.
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
const flowKey = groupPart === undefined ? routedNode.key : JSON.stringify([routedNode.key, groupPart])
return (
<div
ref={wrapperRef}
className={css.flowItem}
data-chat-anchor-key={routedNode.key}
data-chat-flow-key={routedNode.key}
data-chat-anchor-key={flowKey}
data-chat-flow-key={flowKey}
data-chat-node-key={routedNode.key}
data-chat-group-part={groupPart}
data-chat-flow-kind={routedNode.kind}
data-chat-turn={turn}
data-turn-process-member={processMember || undefined}
@@ -3,7 +3,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
import type {
ConversationTimelineSnapshot, RenderMessageImages,
ConversationTimelineSnapshot, NodeKey, RenderEntry, RenderMessageImages,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import {
@@ -13,6 +13,9 @@ import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { ChatGroupSeat } from './ChatGroupSeat.tsx'
import { chatRenderKey } from './render-entry.ts'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { TurnNavigator } from './TurnNavigator.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
@@ -96,14 +99,23 @@ function TurnStatus({ startTime, t }: {
)
}
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey'> & {
readonly order: readonly string[]
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
readonly entries: readonly RenderEntry[]
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
}
const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) {
return order.map(nodeKey => (
<ChatNodeSeat key={nodeKey} nodeKey={nodeKey} {...seatProps} />
))
const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) {
return entries.map((entry) => {
switch (entry.kind) {
case 'node':
return <ChatNodeSeat {...seatProps} key={chatRenderKey(entry)} nodeKey={entry.key}
{...entry.groupPart === undefined ? {} : { groupPart: entry.groupPart }} />
case 'group':
return <ChatGroupSeat {...seatProps} key={chatRenderKey(entry)} groupKey={entry.key} useChatGroup={useChatGroup} />
default:
return assertNever(entry)
}
})
})
/**
@@ -111,11 +123,14 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
* ordered business Node crosses the keyed renderer seat.
*/
export function ChatView({
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot,
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
usePresentation, useProjection, t,
}: ChatViewSlotProps) {
const order = useChat(s => s.order)
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
const entries = useMemo<readonly RenderEntry[]>(() => groupedEntries
?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order])
const nodeStore = useChat(s => s.nodes)
// The rail's items are accumulated in the Chat snapshot, so this selector is
// both the data and its change signal: the array identity moves only when a
@@ -233,7 +248,9 @@ export function ChatView({
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<ChatNodeList
order={order}
entries={entries}
nodeStore={nodeStore}
useChatGroup={useChatGroup}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
usePresentation={usePresentation}
@@ -0,0 +1,16 @@
/** Stable React identities for the two grouping reference kinds. */
import type { RenderEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { assertNever } from '@deepseek-ai/dsh-util-values'
/**
* Identify a rendering position independently of presentation mode.
* @param entry - mode-independent rendering reference.
* @returns its collision-free React key.
*/
export function chatRenderKey(entry: RenderEntry): string {
switch (entry.kind) {
case 'node': return JSON.stringify(['node', entry.key, entry.groupPart ?? null])
case 'group': return JSON.stringify(['group', entry.key])
default: return assertNever(entry)
}
}
@@ -141,10 +141,13 @@ export class ChatViewport {
private anchor(key: string): HTMLElement | null {
if (this.elements === null) return null
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
// Reading anchors name exact parts; Turn navigation names the original Node.
let nodePart: HTMLElement | null = null
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden]):not([hidden] *)')) {
if (row.dataset.chatAnchorKey === key) return row
if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row
}
return null
return nodePart
}
/**
@@ -169,7 +172,8 @@ export class ChatViewport {
}
if (anchor === null) {
const rows = list.querySelectorAll<HTMLElement>(
'[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
':is([data-chat-flow], [data-chat-flow] > [data-chat-group-key])'
+ ' > [data-chat-flow-key]:not([data-chat-group-key]):not(:empty):not([hidden]):not([hidden] *)',
)
let low = 0
let high = rows.length
@@ -229,6 +233,7 @@ export class ChatViewport {
/**
* Align a known loaded turn and return its actual clamped position.
* A split Node anchor selects its first visible part.
* @param turn - loaded turn to align below the scrollport's top edge.
* @returns the actual landing, or null when its anchor is unavailable.
*/
@@ -246,7 +251,7 @@ export class ChatViewport {
*/
scrollToTurnAtOrAfter(turn: number): ViewportLanding | null {
if (this.elements === null) return null
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden])')) {
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden]):not([hidden] *)')) {
const candidate = Number(row.dataset.chatTurn)
if (Number.isSafeInteger(candidate) && candidate >= turn) return this.align(row, 24, candidate)
}
@@ -3,6 +3,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type {
CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
ConversationGroupData, GroupSnapshot,
MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
@@ -86,6 +87,8 @@ export interface ChatNodeTurnDataInjected {
/** Stable owner currency delivered to a keyed Chat renderer. */
export interface ChatNodeOwnerProps {
/** Renderer-owned Node portion selected by the grouping Definition. */
groupPart?: string
cwd?: string | undefined
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
@@ -163,6 +166,8 @@ export interface ChatViewInjected {
chatNode: (key: string) => ChatNodeSource
/** Resolve the stable Turn-process source for one Chat Node key. */
chatNodeProcess: (key: string) => ChatNodeProcessSource
/** Resolve one optional group without subscribing the root View to its data. */
chatGroup: (key: string) => ObservableSnapshot<GroupSnapshot<ConversationGroupData<'chat'>> | undefined> | undefined
}
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
@@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {
ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder,
ConversationViewDefinition, PartialAssistant, RunningToolCall,
ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
@@ -164,6 +164,7 @@ class MutableChatNodeStore implements ChatNodeStore {
class MutableChatLocationIndex implements ChatLocationNodeIndex {
private turns = new Map<number, readonly string[]>()
private steps = new Map<string, readonly string[]>()
private positions = new Map<string, GroupNodePosition>()
getTurn(turn: number): readonly string[] {
return this.turns.get(turn) ?? EMPTY_KEYS
@@ -173,13 +174,32 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS
}
rebuild(order: readonly string[], store: ChatNodeStore): void {
getPosition(key: string): GroupNodePosition | undefined {
return this.positions.get(key)
}
rebuild(order: readonly string[], store: ChatNodeStore): readonly number[] {
const turns = new Map<number, string[]>()
const steps = new Map<string, string[]>()
for (const key of order) {
const positions = new Map<string, GroupNodePosition>()
const changedTurns = new Set<number>()
for (const [index, key] of order.entries()) {
const location = store.get(key)?.location
if (location === undefined) continue
const coordinates = locationCoordinates(location)
const previous = this.positions.get(key)
const position: GroupNodePosition = {
turn: coordinates.turn,
previous: order[index - 1] as NodeKey | undefined,
next: order[index + 1] as NodeKey | undefined,
}
const unchanged = previous !== undefined && previous.turn === position.turn
&& previous.previous === position.previous && previous.next === position.next
positions.set(key, unchanged ? previous : position)
if (!unchanged) {
if (previous?.turn !== undefined) changedTurns.add(previous.turn)
if (position.turn !== undefined) changedTurns.add(position.turn)
}
if (coordinates.turn === undefined) continue
const turnKeys = turns.get(coordinates.turn) ?? []
turnKeys.push(key)
@@ -190,8 +210,13 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
stepKeys.push(key)
steps.set(step, stepKeys)
}
for (const [key, position] of this.positions) {
if (!positions.has(key) && position.turn !== undefined) changedTurns.add(position.turn)
}
this.positions = positions
this.turns = updateIndex(this.turns, turns)
this.steps = updateIndex(this.steps, steps)
return [...changedTurns]
}
/** Invalidate aggregate readers when member data changes without moving. */
@@ -966,12 +991,24 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
private readonly referenceLabels = new ReferenceLabelProjector()
private readonly skillNames = new SkillNameProjector()
private order: readonly string[] = EMPTY_KEYS
private latestGroupInput: ConversationGroupInput<ChatConversationViewNode>
private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key)
private readonly readGroupTurn = (turn: number): readonly NodeKey[] => this.locations.getTurn(turn) as readonly NodeKey[]
private readonly readGroupPosition = (key: NodeKey): GroupNodePosition | undefined => this.locations.getPosition(key)
/** Last published timeline: a Turn boundary can land without a new node. */
private timeline: ConversationTimelineSnapshot | null = null
readonly empty: ChatSnapshot
constructor() {
this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() })
this.latestGroupInput = {
kind: 'replace',
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: this.empty.timeline,
}
}
replace(input: {
@@ -985,21 +1022,31 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.store.replaceProcesses(this.order, this.locations)
this.navigation.rebuild(input.timeline, this.locations, this.store)
this.timeline = input.timeline
this.latestGroupInput = {
kind: 'replace',
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: input.timeline,
}
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
this.store.publish()
return snapshot
}
apply(input: {
readonly upserts: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
readonly changedTurns?: readonly number[]
}): ChatSnapshot {
const upserts = this.skillNames.apply(this.referenceLabels.apply(input.upserts, this.store), this.store)
const processTurns = new Set<number>()
let structural = false
const contentOnly: ChatConversationViewNode[] = []
const changes: NodeChange<ChatConversationViewNode>[] = []
for (const node of upserts) {
const previous = this.store.get(node.key)
if (previous !== node) changes.push({ previous, current: node })
const nodeStructural = previous === undefined
|| previous.kind !== node.kind
|| previous.anchorSeq !== node.anchorSeq
@@ -1015,10 +1062,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
}
}
this.store.upsert(upserts)
let changedTurnOrders: readonly number[] = EMPTY_TURNS
if (structural) {
const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
this.order = sameReferences(this.order, next) ? this.order : next
this.locations.rebuild(this.order, this.store)
changedTurnOrders = this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
this.store.updateProcesses(processTurns, this.locations)
@@ -1028,11 +1076,29 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
}
this.timeline = input.timeline
this.latestGroupInput = {
kind: 'apply',
changes,
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: input.timeline,
changedTurns: input.changedTurns ?? EMPTY_TURNS,
changedTurnOrders,
}
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
this.store.publish()
return snapshot
}
groupInput(): ConversationGroupInput<ChatConversationViewNode> {
return this.latestGroupInput
}
publish(): void {
this.store.publish()
}
private snapshot(
timeline: ConversationTimelineSnapshot,
legacy = this.legacy.replace(EMPTY_LIST, timeline),
@@ -12,6 +12,7 @@ import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runt
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import {
apply as applyConversation, inject as injectConversation,
type GroupKey,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply as applyChat, inject as injectChat, type ChatViewInjected,
@@ -22,6 +23,12 @@ import { createChatStore } from '../src/client/stores.ts'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationGroupDataMap {
chat: number
}
}
usePinnedBrowserLanguages('zh-CN')
const ROOT = 'root-1' as SessionId
@@ -127,6 +134,36 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true)
}
describe('Chat inject API', () => {
it('resolves keyed Group sources across registration, activation, and removal', async () => {
const b = await bench()
try {
const { injected } = b.chatViewApi(b.rootReference)
const key = 'injected-group' as GroupKey
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
const conversation = b.runtime.ctx.uiConversation
const remove = conversation.groups.register({
kind: 'test-group', target: 'chat',
create: () => null,
update: () => null,
buildGroups: () => ({
entries: [{ kind: 'group', key }],
groups: { kind: 'replace', snapshots: [{ key, data: 1, members: [] }] },
}),
})
await Promise.resolve()
conversation.binding(b.rootReference.binding).activate('chat')
const source = injected.keyedHooks.chatGroup(key)
expect(source?.getSnapshot()?.data).toBe(1)
expect(injected.keyedHooks.chatGroup(key)).toBe(source)
remove()
await Promise.resolve()
expect(source?.getSnapshot()).toBeUndefined()
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
} finally {
await b.runtime.dispose()
}
})
it('loads older history and forks through the Session Controller', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
@@ -28,6 +28,44 @@ function userNode(index: number, text = `message ${String(index)}`): ChatConvers
}
describe('Chat Node keyed sources', () => {
it('provides ordered projected Nodes and previous values to grouping', () => {
const builder = new ChatSnapshotBuilder()
const user = userNode(1)
const snapshot = builder.replace({ nodes: [user], timeline })
const initial = builder.groupInput()
expect(initial.kind).toBe('replace')
expect(initial.order).toBe(snapshot.order)
expect(initial.readNode(initial.order[0]!)).toBe(user)
expect(initial.readPosition(initial.order[0]!)).toEqual({ turn: undefined, previous: undefined, next: undefined })
expect(initial.readTurn(7)).toEqual([])
const context: ChatConversationViewNode = {
key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2,
location: { kind: 'session' }, visibility: 'visible',
data: {
kind: 'context', seq: 2, time: 2, content: [],
source: { kind: 'skill-invocation', name: 'demo', form: 'instructions' },
},
}
const next = builder.apply({ upserts: [context], timeline, changedTurns: [7] })
const input = builder.groupInput()
if (input.kind !== 'apply') throw new Error('expected apply')
expect(input.order).toBe(next.order)
expect(input.changedTurns).toEqual([7])
expect(input.changedTurnOrders).toEqual([])
expect(input.readPosition(context.key as typeof input.order[number])).toEqual({
turn: undefined, previous: user.key, next: undefined,
})
expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined()
expect(input.changes.find(change => change.current.key === user.key)).toEqual({
previous: user, current: next.nodes.get(user.key),
})
expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] })
expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context })
builder.apply({ upserts: [], timeline })
expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] })
expect(builder.groupInput().order).toBe(next.order)
})
it('notifies only the updated key among 4,000 mounted sources', () => {
const builder = new ChatSnapshotBuilder()
const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1))
@@ -43,6 +81,9 @@ describe('Chat Node keyed sources', () => {
const changed = userNode(target + 1, 'streamed update')
const next = builder.apply({ upserts: [changed], timeline })
expect(listeners[target]).not.toHaveBeenCalled()
builder.publish()
expect(listeners[target]).toHaveBeenCalledOnce()
expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1)
expect(next.nodes.source(changed.key)).toBe(sources[target])
@@ -16,7 +16,8 @@ import type {
SessionListState, SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ConversationLocationDataStore, ConversationTurnDataMap,
ConversationLocationDataStore, ConversationTurnDataMap, ConversationGroupedView,
ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -47,6 +48,13 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps
import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts'
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationGroupDataMap {
chat: number
}
}
// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
@@ -259,6 +267,18 @@ function makeHarness(
const useChatNodeProcess = bindKeyedSnapshotSelector(
key => chatSource.source.getSnapshot().nodes.processSource(key),
)
let grouped: ConversationGroupedView<number> | undefined
const absentGroup = createSnapshotStore<GroupSnapshot<number> | undefined>(undefined)
const conversation = createSnapshotStore<ConversationSnapshot>({
...EMPTY_CONVERSATION_SNAPSHOT,
views: {
...EMPTY_CONVERSATION_SNAPSHOT.views,
grouped: (() => grouped) as ConversationViewSnapshotStore['grouped'],
},
})
const useChatGroup = bindKeyedSnapshotSelector(
key => grouped?.groupSource(key as GroupKey) ?? absentGroup,
)
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
const openSkill = vi.fn<(name: string) => void>()
const loadOlder = vi.fn()
@@ -380,7 +400,8 @@ function makeHarness(
useChat: bindSnapshotSelector(chatSource.source),
useChatNode,
useChatNodeProcess,
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
useChatGroup,
useConversation: bindSnapshotSelector(conversation),
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
useSessionRetainInfo: () => undefined,
@@ -448,6 +469,10 @@ function makeHarness(
setOutline: (value: unknown) => { outlineValue = value },
chatScroll, forkAt, toolOwners,
setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) },
setGrouped: (value: ConversationGroupedView<number> | undefined) => {
grouped = value
conversation.set({ ...conversation.getSnapshot() })
},
setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
nodeSlotOverride = renderer
@@ -484,10 +509,12 @@ function withSystemPrompt(
visibility: 'visible',
data: { text },
}
return builder.replace({
const next = builder.replace({
nodes: [prompt, ...snapshot.nodes.values()],
timeline: snapshot.timeline,
})
builder.publish()
return next
}
function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
@@ -582,6 +609,125 @@ describe('Chat node rendering', () => {
})
describe('ChatView', () => {
it('renders only referenced Nodes without deleting unreferenced Node data', () => {
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'included'), user(2, 'omitted')] })
const h = makeHarness({}, {}, snapshot)
const [included, omitted] = snapshot.order
if (included === undefined || omitted === undefined) throw new Error('expected two Nodes')
const groupStore = new ConversationGroupStore<number>()
groupStore.prepareAndInstall({
entries: [{ kind: 'node', key: included as NodeKey }],
groups: { kind: 'replace', snapshots: [] },
}, id => snapshot.nodes.get(id))
h.setGrouped(groupStore)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('included')).toBeTruthy()
expect(view.queryByText('omitted')).toBeNull()
expect(snapshot.nodes.get(omitted)).toBeDefined()
})
it('keeps grouped Node instances mounted across presentation modes and group data updates', () => {
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'outside'), user(2, 'inside')] })
const h = makeHarness({}, {}, snapshot)
const [outside, inside] = snapshot.order.map(key => ({ kind: 'node' as const, key: key as NodeKey }))
if (outside === undefined || inside === undefined) throw new Error('expected two Nodes')
const key = 'process' as GroupKey
const groupStore = new ConversationGroupStore<number>()
const record = { key, data: 0, members: [inside] }
groupStore.prepareAndInstall({
entries: [outside, { kind: 'group', key }],
groups: { kind: 'replace', snapshots: [record] },
}, id => snapshot.nodes.get(id))
groupStore.publish()
h.setGrouped(groupStore)
h.setNodeRenderer(((slot: string, owner: object) => {
if (slot !== 'conversation.chat.node' || !('node' in owner)) return null
const node = (owner as RoutedChatNodeOwner).node
return <input aria-label={node.key} defaultValue={node.kind} />
}) as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement
const parent = input.closest('[data-chat-group-key]')
expect(parent).not.toBeNull()
expect(parent?.tagName).toBe('DIV')
fireEvent.change(input, { target: { value: 'retained local input' } })
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
expect(input.value).toBe('retained local input')
expect(input.closest('[data-chat-group-key]')).toBe(parent)
}
act(() => {
groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: 1 }], removes: [] } }, id => snapshot.nodes.get(id))
groupStore.publish()
})
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
expect(view.getByRole('textbox', { name: outside.key }).closest('[data-chat-group-key]')).toBeNull()
act(() => {
groupStore.clear()
groupStore.publish()
})
expect(view.queryByRole('textbox', { name: inside.key })).toBeNull()
act(() => { h.setGrouped(groupStore) })
expect(view.queryByRole('textbox', { name: outside.key })).toBeNull()
})
it('passes independently keyed group parts to business Node renderers', () => {
const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] })
const h = makeHarness({}, {}, snapshot)
const nodeKey = snapshot.order.find(key => snapshot.nodes.get(key)?.kind === 'assistant-step') as NodeKey
const key = 'parts' as GroupKey
const groupStore = new ConversationGroupStore<number>()
groupStore.prepareAndInstall({
entries: [{ kind: 'group', key }, { kind: 'node', key: nodeKey, groupPart: 'response' }],
groups: { kind: 'replace', snapshots: [{ key, data: 0, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] },
}, id => snapshot.nodes.get(id))
h.setGrouped(groupStore)
h.setNodeRenderer(((slot: string, owner: object) => {
if (slot !== 'conversation.chat.node' || !('node' in owner)) return null
return <span>{(owner as RoutedChatNodeOwner).groupPart}</span>
}) as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('reasoning').closest('[data-chat-group-key]')).not.toBeNull()
expect(view.getByText('response').closest('[data-chat-group-key]')).toBeNull()
const anchors = [...view.container.querySelectorAll('[data-chat-group-part]')]
.map(element => element.getAttribute('data-chat-flow-key'))
expect(new Set(anchors).size).toBe(2)
expect([...view.container.querySelectorAll('[data-chat-group-part]')]
.map(element => element.getAttribute('data-chat-node-key'))).toEqual([nodeKey, nodeKey])
})
it('rebinds grouped members when registry rebuilding replaces the Node store', () => {
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'before rebuild')] })
const h = makeHarness({}, {}, snapshot)
const key = 'retained-group' as GroupKey
const members = snapshot.order.map(id => ({ kind: 'node' as const, key: id as NodeKey }))
const groups = new ConversationGroupStore<number>()
groups.prepareAndInstall({
entries: [{ kind: 'group', key }],
groups: { kind: 'replace', snapshots: [{ key, data: 0, members }] },
}, id => snapshot.nodes.get(id))
groups.publish()
h.setGrouped(groups)
const view = render(<h.ChatView {...h.props} />)
const original = view.getByText('before rebuild').closest('[data-chat-anchor-key]')
const builder = new ChatSnapshotBuilder()
const replacement = builder.replace({ nodes: snapshot.nodes.values(), timeline: snapshot.timeline })
act(() => { h.set({ chat: replacement }) })
const current = replacement.nodes.get(members[0]!.key) as ChatNode<'user'>
act(() => {
builder.apply({
upserts: [{ ...current, data: { ...current.data, content: [{ type: 'text', text: 'after rebuild' }] } }],
timeline: snapshot.timeline,
})
builder.publish()
})
expect(view.queryByText('before rebuild')).toBeNull()
expect(view.getByText('after rebuild').closest('[data-chat-anchor-key]')).toBe(original)
expect(groups.groupSource(key).getSnapshot()?.members).toBe(members)
})
it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
const snapshot = chatSnapshotFixture({
nodes: [
@@ -1875,6 +2021,7 @@ describe('ChatView', () => {
act(() => {
turnData.publish()
builder.publish()
h.set({ chat: complete, hasMore: false })
})
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { expect, it, onTestFinished, vi } from 'vitest'
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
function fixture() {
const column = document.createElement('div')
column.dataset.chatFlow = ''
document.body.append(column)
const viewport = new ChatViewport()
const hitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: undefined })
onTestFinished(() => {
viewport.detach()
column.remove()
if (hitTest === undefined) Reflect.deleteProperty(document, 'elementsFromPoint')
else Object.defineProperty(document, 'elementsFromPoint', hitTest)
vi.restoreAllMocks()
})
Object.defineProperties(column, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 2_000 },
})
vi.spyOn(column, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 300))
viewport.attach(column, column)
let prependedHeight = 0
const group = () => {
const element = document.createElement('div')
element.dataset.chatGroupKey = 'group'
element.style.display = 'contents'
column.append(element)
return element
}
const row = (parent: HTMLElement, nodeKey: string, top: number, part?: string) => {
const element = document.createElement('div')
element.textContent = nodeKey
element.dataset.chatNodeKey = nodeKey
element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part])
element.dataset.chatFlowKey = element.dataset.chatAnchorKey
element.dataset.chatTurn = '1'
parent.append(element)
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() =>
new DOMRect(0, top + prependedHeight - column.scrollTop, 500, 60))
return element
}
return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } }
}
it('captures a grouped member and preserves its reading position after history grows above it', () => {
const h = fixture()
const group = h.group()
h.row(group, 'first', 20)
h.row(group, 'second', 130)
h.column.scrollTop = 100
const position = h.viewport.capturePosition()
expect(position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 100 })
if (position === null) throw new Error('expected a grouped reading anchor')
h.prepend(200)
const landing = h.viewport.preserve(position)
expect(landing?.metrics.top).toBe(300)
expect(landing?.position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 300 })
})
it('navigates by Node identity while retaining a distinct visible part anchor', () => {
const h = fixture()
const group = h.group()
const reasoning = h.row(group, 'assistant', 100, 'reasoning')
const response = h.row(group, 'assistant', 200, 'response')
h.viewport.updateTurns([{ turn: 1, anchorKey: 'assistant', prompt: '', response: '' }])
expect(h.viewport.scrollToTurn(1)?.position?.anchorKey).toBe(reasoning.dataset.chatAnchorKey)
reasoning.setAttribute('hidden', 'until-found')
const landing = h.viewport.scrollToTurn(1)
expect(landing?.metrics.top).toBe(176)
expect(landing?.position?.anchorKey).toBe(response.dataset.chatAnchorKey)
expect(h.viewport.restore({ anchorKey: response.dataset.chatAnchorKey!, anchorTop: 8, scrollTop: 0 })?.metrics.top)
.toBe(192)
})
it('excludes empty rows, hidden ancestors, and transparent group shells from fallback sampling', () => {
const h = fixture()
const hidden = h.group()
hidden.setAttribute('hidden', 'until-found')
h.row(hidden, 'hidden', 10)
const group = h.group()
group.dataset.chatFlowKey = 'group'
group.dataset.chatAnchorKey = 'group'
const empty = h.row(group, 'empty', 30)
empty.textContent = ''
h.row(group, 'visible', 70)
expect(h.viewport.capturePosition()?.anchorKey).toBe('visible')
})
it('skips hidden group members when navigating to a fallback Turn row', () => {
const h = fixture()
const hidden = h.group()
hidden.setAttribute('hidden', 'until-found')
h.row(hidden, 'hidden', 10, 'reasoning')
const visible = h.row(h.group(), 'visible', 150, 'response')
const landing = h.viewport.scrollToTurnAtOrAfter(1)
expect(landing?.metrics.top).toBe(126)
expect(landing?.position?.anchorKey).toBe(visible.dataset.chatAnchorKey)
})
it('retains the original capture and Turn navigation path for ungrouped Nodes', () => {
const h = fixture()
h.row(h.column, 'whole', 90)
h.viewport.updateTurns([{ turn: 1, anchorKey: 'whole', prompt: '', response: '' }])
expect(h.viewport.capturePosition()?.anchorKey).toBe('whole')
expect(h.viewport.scrollToTurn(1)?.metrics.top).toBe(66)
})
+3
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/values"
},
{
"path": "../../../vendor/cordis"
},
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726
README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab
README.md: 6f115521a090ca32686af417a7ceea0634324c7d
README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868
@@ -31,6 +31,10 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition.
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work.
<a id="shell-and-standard-props"></a>
@@ -31,6 +31,10 @@ kind: "package-reference"
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。
<a id="shell-and-standard-props"></a>
@@ -1,5 +1,8 @@
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationGroupData, ConversationGroupedView, ConversationGroupInput,
} from './groups.ts'
/** Definition-local identity and lifecycle role extracted from one event. */
export interface ConversationMatchResult {
@@ -141,6 +144,14 @@ export interface ConversationViewSnapshotStore {
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
target: Target,
): ConversationViewSnapshotMap[Target] | undefined
/**
* Read grouping for an activated target without activating another target.
* @param target - registered View target.
* @returns its grouped reader, when a Group Definition is active.
*/
grouped<Target extends string>(
target: Target,
): ConversationGroupedView<ConversationGroupData<Target>> | undefined
}
/** Immutable public view of an assembled business Context. */
@@ -261,6 +272,7 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
replace(input: {
readonly nodes: readonly Node[]
readonly timeline: ConversationTimelineSnapshot
readonly changedTurns?: readonly number[]
}): Snapshot
/**
* Apply only Nodes whose materialized values changed in this transaction.
@@ -270,7 +282,12 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
apply(input: {
readonly upserts: readonly Node[]
readonly timeline: ConversationTimelineSnapshot
readonly changedTurns?: readonly number[]
}): Snapshot
/** @returns the latest target-processed Node inputs; required only for a grouped target. */
groupInput?(): ConversationGroupInput<Node>
/** Publish local sources after all target snapshots and grouping results have been installed. */
publish?(): void
}
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
@@ -0,0 +1,134 @@
/** Definition-owned grouping over a target's already materialized Nodes. */
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { ConversationTimelineSnapshot, ConversationViewNode } from './conversation.ts'
/** Existing Node identity, without allocating another execution Node. */
export type NodeKey = Branded<'ConversationNodeKey'>
/** Definition-local group identity within one Session and target. */
export type GroupKey = Branded<'ConversationGroupKey'>
/** One whole Node or a renderer-owned part of that Node. */
export interface NodeReference {
readonly kind: 'node'
readonly key: NodeKey
readonly groupPart?: string
}
/** Root-list reference to a separately observed group. */
export interface GroupReference {
readonly kind: 'group'
readonly key: GroupKey
}
/** The two supported root rendering positions. */
export type RenderEntry = NodeReference | GroupReference
/** Immutable group data and its ordered, non-nested Node references. */
export interface GroupSnapshot<Data> {
readonly key: GroupKey
readonly data: Data
readonly members: readonly NodeReference[]
}
/**
* One atomic grouping update. Replacement input requires entries and a complete
* group replacement; apply input may omit entries to preserve the root list.
* Group removal never deletes source Nodes.
*/
export interface GroupUpdate<Data> {
/** Complete root sequence; only its Node references and referenced group members render. */
readonly entries?: readonly RenderEntry[]
readonly groups:
| { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot<Data>[] }
| {
readonly kind: 'apply'
readonly upserts: readonly GroupSnapshot<Data>[]
readonly removes: readonly GroupKey[]
}
}
/** Target-processed Node values before and after one update. */
export interface NodeChange<Node> {
readonly previous: Node | undefined
readonly current: Node
}
/** Owning Turn and immediate neighbours in the target's visible Node order. */
export interface GroupNodePosition {
readonly turn: number | undefined
readonly previous: NodeKey | undefined
readonly next: NodeKey | undefined
}
interface GroupInputNodes<Node> {
readonly order: readonly NodeKey[]
/** @param key - target Node identity. @returns its current value during this synchronous update. */
readonly readNode: (key: NodeKey) => Node | undefined
/** @param turn - owning Turn. @returns its visible keys in target order; empty when absent. */
readonly readTurn: (turn: number) => readonly NodeKey[]
/** @param key - target Node identity. @returns its visible position, or absence outside the target order. */
readonly readPosition: (key: NodeKey) => GroupNodePosition | undefined
readonly timeline: ConversationTimelineSnapshot
}
/** Target-local inputs; apply includes lifecycle changes without Node upserts. */
export type ConversationGroupInput<Node> = GroupInputNodes<Node> & (
| { readonly kind: 'replace' }
| {
readonly kind: 'apply'
readonly changes: readonly NodeChange<Node>[]
readonly changedTurns: readonly number[]
/** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */
readonly changedTurnOrders: readonly number[]
}
)
/** Framework-owned State for one Session's registered Group Definition. */
export interface ConversationGroupContext<State> {
readonly state: State
}
/** Business grouping rules, driven after the target Builder has installed Nodes. */
export interface ConversationGroupDefinition<
Node extends ConversationViewNode = ConversationViewNode,
State = unknown,
Data = unknown,
> {
readonly kind: string
readonly target: string
/** @returns initial Session-local grouping State. */
create(): State
/**
* Consume target Node changes, not raw Session events or presentation modes.
* @param context - current Session-local State.
* @param input - current target inputs; its readers are valid only during this call.
* @returns State adopted by the framework.
*/
update(context: ConversationGroupContext<State>, input: ConversationGroupInput<Node>): State
/**
* Materialize pending grouping changes without changing the business State.
* @param context - State adopted after update.
* @returns complete output for replace input; an update or null when unchanged for apply input.
*/
buildGroups(context: ConversationGroupContext<State>): GroupUpdate<Data> | null
}
/** Merge-extensible group data associated with each registered target. */
export interface ConversationGroupDataMap {}
/** Group data declared by a target; undeclared targets have no group payload. */
export type ConversationGroupData<Target extends string> =
Target extends keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never
/** Stable grouped reader; data-only updates preserve entries and member arrays. */
export interface ConversationGroupedView<Data> {
readonly entries: readonly RenderEntry[]
/**
* Observe a group without subscribing to the root sequence.
* @param key - group identity within this target.
* @returns an identity-stable source, whose value is absent after removal.
*/
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined>
}
@@ -10,7 +10,7 @@ export interface ConversationSnapshot {
/** Empty Conversation value used before a Session binding is available. */
export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = {
views: { get: () => undefined },
views: { get: () => undefined, grouped: () => undefined },
activeTargets: new Set(),
}
@@ -11,6 +11,10 @@ import type {
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import { conversationContextKey } from '../contract/conversation.ts'
import type {
ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView,
} from '../contract/groups.ts'
import { ConversationGroupStore } from './group-store.ts'
import {
ConversationLocationIndex, type ConversationLocationDataChange,
} from './location-index.ts'
@@ -46,11 +50,18 @@ interface PendingMatch {
interface ViewState {
readonly target: string
readonly definition: ConversationViewDefinition
readonly groupDefinition: ConversationGroupDefinition | undefined
readonly isActive: ((snapshot: unknown) => boolean) | undefined
builder: ConversationViewBuilder | undefined
snapshot: unknown
}
interface GroupState {
readonly definition: ConversationGroupDefinition
state: unknown
readonly store: ConversationGroupStore<unknown>
}
const PUBLICATION_RANK: Record<ConversationPublication, number> = {
none: 0,
'animation-frame': 1,
@@ -152,6 +163,16 @@ export interface ConversationViewDefinitions {
entries(): readonly ConversationViewDefinition[]
}
/** Registered grouping rules consumed by the target-neutral assembler. */
export interface ConversationGroupDefinitions {
/** @returns grouping Definitions in registration order. */
entries(): readonly ConversationGroupDefinition[]
/** @param target - View target. @returns its optional Group Definition. */
forTarget(target: string): ConversationGroupDefinition | undefined
}
const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined }
/**
* Session-owned incremental engine that assembles business Contexts from a
* contiguous Event window and materializes registered view snapshots.
@@ -168,6 +189,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private readonly revised = new Set<InternalContext>()
private readonly dependents = new Map<string, Set<InternalContext>>()
private readonly views = new Map<string, ViewState>()
private readonly groups = new Map<string, GroupState>()
private readonly pendingGroupStores = new Set<ConversationGroupStore<unknown>>()
private readonly activeTargets = new Set<string>()
private hasMore = false
private replacePending = true
@@ -176,10 +199,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
/**
* @param eventDefinitions - live Event Definition registry.
* @param viewDefinitions - live view builder registry.
* @param groupDefinitions - optional registered grouping rules, independent of presentation modes.
*/
constructor(
private readonly eventDefinitions: ConversationEventDefinitions,
private readonly viewDefinitions: ConversationViewDefinitions,
private readonly groupDefinitions: ConversationGroupDefinitions = NO_GROUPS,
) {
this.resetViewBuilders()
}
@@ -333,28 +358,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
if (this.replacePending) {
this.replaceLocationData()
let published = false
const updated: ViewState[] = []
const changedTurns = this.locationIndex.takeChangedTurns()
for (const target of this.activeTargets) {
const view = this.views.get(target)
if (view === undefined) continue
const builder = view.builder ?? view.definition.create()
view.builder = builder
view.snapshot = builder.replace({
nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
timeline: this.locationIndex.snapshot(),
})
published = true
this.updateView(view, true, this.buildTargetNodes(target, this.contextsByTarget.get(target)), changedTurns)
updated.push(view)
}
this.locationIndex.publishData()
this.replacePending = false
this.dirty.clear()
this.dirtyByTarget.clear()
this.timelineDirty = false
return published
return this.publishViews(updated)
}
let published = false
const updated: ViewState[] = []
if (this.applyDirtyLocationData()) this.timelineDirty = true
const changedTurns = this.locationIndex.takeChangedTurns()
const timelineDirty = this.timelineDirty
for (const target of this.activeTargets) {
const view = this.views.get(target)
@@ -363,17 +384,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
if (builder === undefined) continue
const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target))
if (upserts.length === 0 && !timelineDirty) continue
view.snapshot = builder.apply({
upserts,
timeline: this.locationIndex.snapshot(),
})
published = true
this.updateView(view, false, upserts, changedTurns)
updated.push(view)
}
this.locationIndex.publishData()
this.dirty.clear()
this.dirtyByTarget.clear()
this.timelineDirty = false
return published
return this.publishViews(updated)
}
/**
@@ -389,6 +406,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
this.activeTargets.add(target)
if (view === undefined) return published
this.replaceView(view)
this.publishViews([view])
return true
}
@@ -407,6 +425,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
}
grouped<Target extends string>(
target: Target,
): ConversationGroupedView<ConversationGroupData<Target>> | undefined {
return this.groups.get(target)?.store as ConversationGroupedView<ConversationGroupData<Target>> | undefined
}
/**
* Read targets whose owners classify their latest snapshot as visible activity.
* @returns target ids contributing visible activity.
@@ -827,12 +851,55 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private replaceView(view: ViewState): void {
this.updateView(view, true, this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), [])
}
private updateView(
view: ViewState,
replacing: boolean,
nodes: readonly ConversationViewNode[],
changedTurns: readonly number[],
): void {
const builder = view.builder ?? view.definition.create()
const definition = view.groupDefinition
if (definition !== undefined && builder.groupInput === undefined) {
throw new Error(`conversation group target "${view.target}" requires builder.groupInput()`)
}
view.builder = builder
view.snapshot = builder.replace({
nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)),
timeline: this.locationIndex.snapshot(),
})
const timeline = this.locationIndex.snapshot()
const snapshot = replacing
? builder.replace({ nodes, timeline, changedTurns })
: builder.apply({ upserts: nodes, timeline, changedTurns })
if (definition !== undefined && builder.groupInput !== undefined) {
let context = this.groups.get(view.target)
const initial = context === undefined
if (context === undefined) {
context = { definition, state: definition.create(), store: new ConversationGroupStore() }
}
const input = builder.groupInput()
context.state = definition.update(context, input)
const change = definition.buildGroups(context)
if ((initial || input.kind === 'replace')
&& (change === null || change.entries === undefined || change.groups.kind !== 'replace')) {
throw new Error(`conversation group target "${view.target}" requires complete grouping for replacement input`)
}
if (change !== null) {
context.store.prepareAndInstall(change, input.readNode)
this.pendingGroupStores.add(context.store)
}
this.groups.set(view.target, context)
}
view.snapshot = snapshot
}
private publishViews(updated: readonly ViewState[]): boolean {
const changed = updated.length > 0 || this.pendingGroupStores.size > 0
const stores = [...this.pendingGroupStores]
this.pendingGroupStores.clear()
for (const view of updated) view.builder?.publish?.()
for (const store of stores) store.publish()
this.locationIndex.publishData()
return changed
}
private buildTargetNodes(
@@ -925,11 +992,21 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private resetViewBuilders(): void {
const definitions = this.viewDefinitions.entries()
const targets = new Set(definitions.map(definition => definition.target))
for (const [target, group] of this.groups) {
if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) {
group.store.clear()
this.pendingGroupStores.add(group.store)
this.groups.delete(target)
}
}
this.views.clear()
for (const definition of this.viewDefinitions.entries()) {
for (const definition of definitions) {
const view: ViewState = {
target: definition.target,
definition,
groupDefinition: this.groupDefinitions.forTarget(definition.target),
isActive: definition.isActive === undefined
? undefined
: snapshot => definition.isActive?.(snapshot) === true,
@@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts'
import { ConversationEventRegistry } from './event-registry.ts'
import { HistoricalImageCache } from './historical-images.ts'
import { ConversationViewRegistry } from './view-registry.ts'
import { ConversationGroupRegistry } from './group-registry.ts'
/** Observable faces published for one Session's Conversation assembly. */
export interface ConversationBinding {
@@ -178,6 +179,8 @@ export class UiConversation extends Service {
readonly events: ConversationEventRegistry
/** Registry of target View definitions. */
readonly views: ConversationViewRegistry
/** Business grouping rules over already materialized target Nodes. */
readonly groups: ConversationGroupRegistry
private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
private readonly images: HistoricalImageCache
@@ -189,6 +192,7 @@ export class UiConversation extends Service {
super(ctx, 'uiConversation')
this.events = new ConversationEventRegistry(ctx)
this.views = new ConversationViewRegistry(ctx)
this.groups = new ConversationGroupRegistry(ctx, this.views)
this.images = new HistoricalImageCache(ctx, sessions)
const rebuild = (): void => {
for (const record of this.bindings.values) record.binding.rebuild()
@@ -205,7 +209,9 @@ export class UiConversation extends Service {
ctx.effect(() => {
const disposeEvents = this.events.subscribe(scheduleRebuild)
const disposeViews = this.views.subscribe(scheduleRebuild)
const disposeGroups = this.groups.subscribe(scheduleRebuild)
return () => {
disposeGroups()
disposeViews()
disposeEvents()
for (const record of [...this.bindings.values]) this.drop(record, true)
@@ -229,7 +235,7 @@ export class UiConversation extends Service {
if (current !== undefined) return current.binding
const binding = new BoundConversation(
owner.eventSource,
new ConversationNodeAssembler(this.events, this.views),
new ConversationNodeAssembler(this.events, this.views, this.groups),
)
const record: BindingRecord = { source: owner, binding, disposeScope: () => {} }
this.bindings.set(owner, record)
@@ -0,0 +1,48 @@
/** Target-indexed business grouping registrations with the existing effect lifecycle. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConversationViewNode } from '../contract/conversation.ts'
import type {
ConversationGroupData, ConversationGroupDefinition,
} from '../contract/groups.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
import type { ConversationViewRegistry } from './view-registry.ts'
/** One Group Definition per target; registration never instantiates its Builder. */
export class ConversationGroupRegistry extends ConversationDefinitionRegistry<ConversationGroupDefinition> {
/**
* @param ctx - owning plugin context.
* @param views - registered target Builder definitions.
*/
constructor(ctx: Context, private readonly views: ConversationViewRegistry) {
super(ctx)
}
/**
* Register grouping rules for an existing target.
* @param definition - business State and grouping output for the declared target data.
* @returns the effect-owned, idempotent registration disposer.
*/
register<Node extends ConversationViewNode, State, Target extends string>(
definition: ConversationGroupDefinition<Node, State, ConversationGroupData<Target>>
& { readonly target: Target },
): () => void {
if (!this.views.entries().some(view => view.target === definition.target)) {
throw new Error(`conversation group target "${definition.target}" is not registered`)
}
return this.registerDefinition(
definition.target,
definition,
`conversation group target "${definition.target}" is already registered`,
`uiConversation.groups.register(${JSON.stringify(definition.target)})`,
)
}
/**
* Find the grouping rules registered for one target.
* @param target - View target.
* @returns its grouping Definition, when registered.
*/
forTarget(target: string): ConversationGroupDefinition | undefined {
return this.definitions.get(target)
}
}
@@ -0,0 +1,229 @@
/** Keyed group publication and incremental validation of rendering positions. */
import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import type { ConversationViewNode } from '../contract/conversation.ts'
import type {
ConversationGroupedView, GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry,
} from '../contract/groups.ts'
type Parts = Set<string | undefined>
interface GroupSource<Data> {
readonly observable: ObservableSnapshot<GroupSnapshot<Data> | undefined>
readonly publication: SnapshotStore<GroupSnapshot<Data> | undefined>
}
interface GroupChanges<Data> {
readonly upserts: Map<GroupKey, GroupSnapshot<Data>>
readonly removes: Set<GroupKey>
}
function sameNodeReference(left: NodeReference, right: NodeReference): boolean {
return left.key === right.key && left.groupPart === right.groupPart
}
function sameEntry(left: RenderEntry, right: RenderEntry): boolean {
return left.kind === right.kind && left.key === right.key
&& (left.kind === 'group' || (right.kind === 'node' && left.groupPart === right.groupPart))
}
function reuseReferences<Value>(
previous: readonly Value[],
next: readonly Value[],
equal: (left: Value, right: Value) => boolean,
): readonly Value[] {
return previous === next || (previous.length === next.length
&& previous.every((value, index) => equal(value, next[index] as Value)))
? previous
: next
}
/** Validates a complete batch before installing its group and root-list changes. */
export class ConversationGroupStore<Data> implements ConversationGroupedView<Data> {
private root: readonly RenderEntry[] = []
private rootGroups = new Set<GroupKey>()
private readonly groups = new Map<GroupKey, GroupSnapshot<Data>>()
private readonly placements = new Map<NodeKey, Parts>()
private readonly sources = new Map<GroupKey, GroupSource<Data>>()
private readonly dirty = new Set<GroupKey>()
/** @returns the identity-stable ordered root references. */
get entries(): readonly RenderEntry[] {
return this.root
}
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined> {
let source = this.sources.get(key)
if (source === undefined) {
const publication = createSnapshotStore(this.groups.get(key))
source = {
publication,
observable: {
getSnapshot: () => this.groups.get(key),
subscribe: listener => publication.subscribe(listener),
},
}
this.sources.set(key, source)
}
return source.observable
}
/**
* Install one validated update without notifying readers.
* @param update - root replacement and complete or incremental group records.
* @param readNode - synchronous reader of the current target Nodes.
*/
prepareAndInstall(
update: GroupUpdate<Data>,
readNode: (key: NodeKey) => ConversationViewNode | undefined,
): void {
const nextRoot = update.entries === undefined
? this.root
: reuseReferences(this.root, update.entries, sameEntry)
const nextRootGroups = nextRoot === this.root ? this.rootGroups : this.collectRootGroups(nextRoot)
const { upserts, removes } = this.collectChanges(update)
const replaceReferences = update.groups.kind === 'replace'
const size = this.groups.size - removes.size
+ [...upserts.keys()].filter(key => !this.groups.has(key)).length
if (size !== nextRootGroups.size) {
throw new Error('conversation group records and root references must correspond one-to-one')
}
for (const key of removes) {
if (nextRootGroups.has(key)) throw new Error(`conversation group "${key}" is still referenced`)
}
for (const key of upserts.keys()) {
if (!nextRootGroups.has(key)) throw new Error(`conversation group "${key}" has no root reference`)
}
if (nextRootGroups !== this.rootGroups) {
for (const key of nextRootGroups) {
if (!upserts.has(key) && !this.groups.has(key)) {
throw new Error(`conversation root references missing group "${key}"`)
}
}
}
const affectedParts = new Map<NodeKey, Parts>()
const partsOf = (key: NodeKey): Parts => {
let parts = affectedParts.get(key)
if (parts === undefined) {
parts = new Set(this.placements.get(key))
affectedParts.set(key, parts)
}
return parts
}
const removeReference = (reference: NodeReference): void => {
partsOf(reference.key).delete(reference.groupPart)
}
const addReference = (reference: NodeReference): void => {
if (readNode(reference.key) === undefined) {
throw new Error(`conversation group references missing Node "${reference.key}"`)
}
const parts = partsOf(reference.key)
if (parts.has(reference.groupPart)
|| (reference.groupPart === undefined && parts.size > 0)
|| parts.has(undefined)) {
throw new Error(`conversation Node "${reference.key}" has overlapping rendering positions`)
}
parts.add(reference.groupPart)
}
if (replaceReferences || nextRoot !== this.root) {
for (const entry of this.root) if (entry.kind === 'node') removeReference(entry)
}
for (const key of removes) {
for (const member of (this.groups.get(key) as GroupSnapshot<Data>).members) removeReference(member)
}
for (const [key, next] of upserts) {
const previous = this.groups.get(key)
if (previous !== undefined && (replaceReferences || previous.members !== next.members)) {
for (const member of previous.members) removeReference(member)
}
}
if (replaceReferences || nextRoot !== this.root) {
for (const entry of nextRoot) if (entry.kind === 'node') addReference(entry)
}
for (const [key, next] of upserts) {
if (replaceReferences || this.groups.get(key)?.members !== next.members) {
for (const member of next.members) addReference(member)
}
}
for (const [key, parts] of affectedParts) {
if (parts.size === 0) this.placements.delete(key)
else this.placements.set(key, parts)
}
for (const key of removes) {
this.groups.delete(key)
this.dirty.add(key)
}
for (const [key, next] of upserts) {
if (this.groups.get(key) === next) continue
this.groups.set(key, next)
this.dirty.add(key)
}
this.root = nextRoot
this.rootGroups = nextRootGroups
}
/** Publish changed group sources after all related target data has been installed. */
publish(): void {
const keys = [...this.dirty]
this.dirty.clear()
for (const key of keys) this.sources.get(key)?.publication.set(this.groups.get(key))
}
/** Remove grouping without deleting its source Nodes; publication remains deferred. */
clear(): void {
this.prepareAndInstall(
{ entries: [], groups: { kind: 'replace', snapshots: [] } },
/* v8 ignore next -- an empty replacement never reads a Node reference. */
() => undefined,
)
}
private collectRootGroups(entries: readonly RenderEntry[]): Set<GroupKey> {
const keys = new Set<GroupKey>()
for (const entry of entries) {
if (entry.kind !== 'group') continue
if (keys.has(entry.key)) throw new Error(`conversation group "${entry.key}" has duplicate root references`)
keys.add(entry.key)
}
return keys
}
private collectChanges(update: GroupUpdate<Data>): GroupChanges<Data> {
const upserts = new Map<GroupKey, GroupSnapshot<Data>>()
const removes = new Set<GroupKey>()
const add = (snapshot: GroupSnapshot<Data>): void => {
if (upserts.has(snapshot.key)) throw new Error(`conversation group "${snapshot.key}" has duplicate upserts`)
const previous = this.groups.get(snapshot.key)
if (previous === undefined) {
upserts.set(snapshot.key, snapshot)
return
}
const members = reuseReferences(previous.members, snapshot.members, sameNodeReference)
upserts.set(snapshot.key, previous.data === snapshot.data && previous.members === members
? previous
: { ...snapshot, members })
}
switch (update.groups.kind) {
case 'replace':
for (const snapshot of update.groups.snapshots) add(snapshot)
for (const key of this.groups.keys()) if (!upserts.has(key)) removes.add(key)
break
case 'apply':
for (const snapshot of update.groups.upserts) add(snapshot)
for (const key of update.groups.removes) {
if (removes.has(key)) throw new Error(`conversation group "${key}" has duplicate removals`)
if (!this.groups.has(key)) throw new Error(`conversation group "${key}" cannot be removed because it is absent`)
if (upserts.has(key)) throw new Error(`conversation group "${key}" cannot be upserted and removed together`)
removes.add(key)
}
break
/* v8 ignore next 2 -- closed update union; TypeScript rejects other operation tags. */
default:
assertNever(update.groups)
}
return { upserts, removes }
}
}
@@ -183,6 +183,7 @@ export class ConversationLocationIndex {
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
private readonly dirtyDataStores = new Set<MutableLocationDataStore>()
private readonly changedTurns = new Set<number>()
private currentTurn: number | undefined
private currentStep: number | undefined
@@ -194,6 +195,16 @@ export class ConversationLocationIndex {
return this.timeline
}
/**
* Drain the Turn changes accumulated for one assembly flush.
* @returns Turn identities changed since the preceding drain.
*/
takeChangedTurns(): readonly number[] {
const turns = [...this.changedTurns]
this.changedTurns.clear()
return turns
}
/**
* Replace all Definition-owned Location values while preserving reader identities.
* @param entries - complete current set of Definition-owned Location values.
@@ -386,6 +397,9 @@ export class ConversationLocationIndex {
}
}
}
for (const turn of new Set([...previousTurns.keys(), ...nextTurns.keys()])) {
if (previousTurns.get(turn) !== nextTurns.get(turn)) this.changedTurns.add(turn)
}
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
? this.timeline
: { turnOrder, turns: nextTurns }
@@ -480,6 +494,7 @@ export class ConversationLocationIndex {
? [...this.timeline.turnOrder, turnNumber]
: this.timeline.turnOrder
this.timeline = { turnOrder, turns }
if (turn !== previousTurn) this.changedTurns.add(turnNumber)
const changed = new Set<number>()
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
@@ -572,19 +587,22 @@ export class ConversationLocationIndex {
}
private mutableTurnData(turn: number): MutableLocationDataStore {
const current = this.turnDataStores.get(turn) ?? this.createDataStore()
const current = this.turnDataStores.get(turn) ?? this.createDataStore(turn)
this.turnDataStores.set(turn, current)
return current
}
private mutableStepData(key: string): MutableLocationDataStore {
const current = this.stepDataStores.get(key) ?? this.createDataStore()
const current = this.stepDataStores.get(key) ?? this.createDataStore(Number(key.slice(0, key.indexOf(':'))))
this.stepDataStores.set(key, current)
return current
}
private createDataStore(): MutableLocationDataStore {
return new MutableLocationDataStore(store => this.dirtyDataStores.add(store))
private createDataStore(turn: number): MutableLocationDataStore {
return new MutableLocationDataStore((store) => {
this.dirtyDataStores.add(store)
this.changedTurns.add(turn)
})
}
private storeFor(data: ConversationLocationData): MutableLocationDataStore {
@@ -3,6 +3,13 @@ export { apply, Config, inject } from './apply.ts'
export type { Config as ConversationConfig } from './apply.ts'
export { UiConversation } from './conversation/assembly.ts'
export type { ConversationBinding } from './conversation/assembly.ts'
export type { ConversationGroupRegistry } from './conversation/group-registry.ts'
export type {
ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap,
ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput,
GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey,
NodeReference, RenderEntry,
} from './contract/groups.ts'
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
export type { IConversation } from './service.ts'
export type {
@@ -40,7 +47,7 @@ export type { ConversationStoreState, ConversationViewRequest, ViewTab } from '.
export { ConversationNodeAssembler } from './conversation/assembler.ts'
export type {
ConversationEventDefinitions, ConversationViewDefinitions,
ConversationEventDefinitions, ConversationGroupDefinitions, ConversationViewDefinitions,
} from './conversation/assembler.ts'
export { ConversationDefinitionRegistry } from './conversation/definition-registry.ts'
export { ConversationEventRegistry } from './conversation/event-registry.ts'
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff. Show more