From 47ae64ee68b416cc0aa78891892ae9e9be6590c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 12:39:53 +0800 Subject: [PATCH 01/36] feat(voice-input): add optional local SenseVoice dictation --- ...6-09-16-experimental-voice-input.i18n.yaml | 6 + .../2026-09-16-experimental-voice-input.md | 35 +++ .../2026-09-16-experimental-voice-input.zh.md | 35 +++ THIRD_PARTY_NOTICES.md | 1 + apps/cli/package.json | 3 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/electron-builder.config.mjs | 1 + apps/desktop/src/main.ts | 2 + apps/desktop/src/microphone-permissions.ts | 31 ++ apps/desktop/tests/macos-signature.spec.ts | 1 + apps/desktop/tests/main-startup.spec.ts | 3 +- .../tests/microphone-permissions.spec.ts | 51 ++++ apps/web/package.json | 4 +- apps/web/tests/scaffold.ts | 6 +- apps/web/tests/voice-input.e2e.ts | 82 +++++ apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 11 + docs/capability-seams.zh.md | 11 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 90 ++++++ docs/config-catalog.zh.md | 90 ++++++ docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 + docs/subsystems/slots.zh.md | 2 + docs/subsystems/voice-input.i18n.yaml | 6 + docs/subsystems/voice-input.md | 158 ++++++++++ docs/subsystems/voice-input.zh.md | 158 ++++++++++ packages/boot/app-boot/src/profile.ts | 1 + .../tests/message-image.client.spec.tsx | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 2 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 + packages/client/ui-conversation/README.zh.md | 4 + .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/input.ts | 9 + .../src/client/contract/slots.ts | 10 +- .../src/client/input/editor/runtime.ts | 13 + .../src/client/input/facade.ts | 6 + .../src/client/skeleton/InputBar.module.css | 7 + .../src/client/skeleton/InputBar.tsx | 21 +- .../tests/async-insertion.client.spec.ts | 38 +++ .../tests/input-bar.client.spec.tsx | 23 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../src/client/PluginManagerPage.tsx | 8 +- .../ui-plugin-manager/src/client/index.ts | 3 +- .../ui-plugin-manager/src/client/locales.ts | 4 + .../src/client/presentation.ts | 1 + .../src/client/slot-contract.ts | 8 + .../tests/components.client.spec.tsx | 11 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/StateDot.module.css | 5 + .../client/ui-primitives/src/StateDot.tsx | 9 +- .../client/ui-primitives/src/icons/index.tsx | 7 + .../tests/state-dot.client.spec.tsx | 8 + .../ui-trajectory/tests/views.client.spec.tsx | 4 + .../tests/plan-review-panel.client.spec.tsx | 2 + .../user-questions-composer.client.spec.tsx | 2 + .../tests/workflow-run.client.spec.tsx | 2 + packages/experimental/README.i18n.yaml | 4 +- packages/experimental/README.md | 5 + packages/experimental/README.zh.md | 5 + .../api-speech-to-text/README.i18n.yaml | 6 + .../experimental/api-speech-to-text/README.md | 78 +++++ .../api-speech-to-text/README.zh.md | 78 +++++ .../api-speech-to-text/package.json | 60 ++++ .../api-speech-to-text/src/index.ts | 110 +++++++ .../api-speech-to-text/src/types.ts | 25 ++ .../tests/controller.spec.ts | 62 ++++ .../api-speech-to-text/tests/wave.spec.ts | 28 ++ .../api-speech-to-text/tsconfig.json | 24 ++ .../api-speech-to-text/tsdown.config.ts | 3 + .../client-ui-voice-input/README.i18n.yaml | 6 + .../client-ui-voice-input/README.md | 76 +++++ .../client-ui-voice-input/README.zh.md | 76 +++++ .../client-ui-voice-input/package.json | 97 ++++++ .../src/client/PreparationCard.tsx | 142 +++++++++ .../src/client/VoiceInput.module.css | 36 +++ .../src/client/VoiceInput.tsx | 151 ++++++++++ .../src/client/Waveform.tsx | 33 +++ .../client-ui-voice-input/src/client/audio.ts | 137 +++++++++ .../client-ui-voice-input/src/client/index.ts | 15 + .../src/client/locales.ts | 160 ++++++++++ .../client-ui-voice-input/src/client/mount.ts | 61 ++++ .../src/client/readiness.ts | 40 +++ .../src/css-modules.d.ts | 5 + .../client-ui-voice-input/src/index.ts | 3 + .../tests/audio.client.spec.ts | 128 ++++++++ .../tests/mount.client.spec.ts | 87 ++++++ .../tests/preparation.client.spec.tsx | 134 +++++++++ .../tests/readiness.client.spec.ts | 59 ++++ .../tests/voice-input.client.spec.tsx | 202 +++++++++++++ .../tests/waveform.client.spec.tsx | 25 ++ .../client-ui-voice-input/tsconfig.json | 60 ++++ .../client-ui-voice-input/tsdown.config.ts | 3 + .../README.i18n.yaml | 6 + .../speech-to-text-sensevoice/README.md | 78 +++++ .../speech-to-text-sensevoice/README.zh.md | 78 +++++ .../speech-to-text-sensevoice/package.json | 53 ++++ .../runtime/assets.json | 28 ++ .../speech-to-text-sensevoice/src/config.ts | 71 +++++ .../speech-to-text-sensevoice/src/index.ts | 43 +++ .../src/inference.ts | 81 +++++ .../src/process-server.ts | 51 ++++ .../src/recognizer.ts | 280 ++++++++++++++++++ .../speech-to-text-sensevoice/src/runtime.ts | 126 ++++++++ .../speech-to-text-sensevoice/src/worker.ts | 13 + .../tests/inference.spec.ts | 55 ++++ .../tests/installer.spec.ts | 82 +++++ .../tests/local.e2e.ts | 31 ++ .../tests/process-server.spec.ts | 30 ++ .../tests/protocol.spec.ts | 77 +++++ .../tests/provider.spec.ts | 37 +++ .../tests/runtime.spec.ts | 62 ++++ .../tests/worker-source.fixture.ts | 2 + .../tests/worker.fixture.mjs | 22 ++ .../tests/worker.spec.ts | 269 +++++++++++++++++ .../speech-to-text-sensevoice/tsconfig.json | 27 ++ .../tsdown.config.ts | 3 + .../speech-to-text/README.i18n.yaml | 6 + .../experimental/speech-to-text/README.md | 80 +++++ .../experimental/speech-to-text/README.zh.md | 80 +++++ .../experimental/speech-to-text/package.json | 50 ++++ .../experimental/speech-to-text/src/index.ts | 204 +++++++++++++ .../experimental/speech-to-text/src/types.ts | 115 +++++++ .../experimental/speech-to-text/src/wave.ts | 23 ++ .../speech-to-text/tests/service.spec.ts | 152 ++++++++++ .../experimental/speech-to-text/tsconfig.json | 24 ++ .../speech-to-text/tsdown.config.ts | 3 + .../voice-input-bundle/README.i18n.yaml | 6 + .../experimental/voice-input-bundle/README.md | 76 +++++ .../voice-input-bundle/README.zh.md | 76 +++++ .../voice-input-bundle/cordis.patch.yml | 11 + .../voice-input-bundle/package.json | 50 ++++ .../voice-input-bundle/src/index.ts | 2 + .../voice-input-bundle/tsconfig.json | 15 + .../voice-input-bundle/tsdown.config.ts | 3 + .../src/client/slot-catalog.ts | 103 ++++++- .../extensions/tool-cordis/src/api-catalog.ts | 174 +++++++++++ pnpm-lock.yaml | 236 ++++++++++++++- scripts/check-workspace-constraints.spec.ts | 10 + scripts/check-workspace-constraints.ts | 1 + scripts/gen-cordis-catalog.ts | 20 ++ scripts/gen-doc-graphs.ts | 16 + .../verify-package-readme-model-experience.ts | 5 + .../web/voice-input/recording.expected.md | 8 + snapshots/web/voice-input/snapshot.yml | 9 + snapshots/web/voice-input/ui.expected.md | 22 ++ tsconfig.base.json | 10 + tsconfig.client.json | 2 + tsconfig.host.json | 6 + 161 files changed, 6420 insertions(+), 59 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md create mode 100644 apps/desktop/src/microphone-permissions.ts create mode 100644 apps/desktop/tests/microphone-permissions.spec.ts create mode 100644 apps/web/tests/voice-input.e2e.ts create mode 100644 docs/subsystems/voice-input.i18n.yaml create mode 100644 docs/subsystems/voice-input.md create mode 100644 docs/subsystems/voice-input.zh.md create mode 100644 packages/client/ui-conversation/tests/async-insertion.client.spec.ts create mode 100644 packages/experimental/api-speech-to-text/README.i18n.yaml create mode 100644 packages/experimental/api-speech-to-text/README.md create mode 100644 packages/experimental/api-speech-to-text/README.zh.md create mode 100644 packages/experimental/api-speech-to-text/package.json create mode 100644 packages/experimental/api-speech-to-text/src/index.ts create mode 100644 packages/experimental/api-speech-to-text/src/types.ts create mode 100644 packages/experimental/api-speech-to-text/tests/controller.spec.ts create mode 100644 packages/experimental/api-speech-to-text/tests/wave.spec.ts create mode 100644 packages/experimental/api-speech-to-text/tsconfig.json create mode 100644 packages/experimental/api-speech-to-text/tsdown.config.ts create mode 100644 packages/experimental/client-ui-voice-input/README.i18n.yaml create mode 100644 packages/experimental/client-ui-voice-input/README.md create mode 100644 packages/experimental/client-ui-voice-input/README.zh.md create mode 100644 packages/experimental/client-ui-voice-input/package.json create mode 100644 packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx create mode 100644 packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css create mode 100644 packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx create mode 100644 packages/experimental/client-ui-voice-input/src/client/Waveform.tsx create mode 100644 packages/experimental/client-ui-voice-input/src/client/audio.ts create mode 100644 packages/experimental/client-ui-voice-input/src/client/index.ts create mode 100644 packages/experimental/client-ui-voice-input/src/client/locales.ts create mode 100644 packages/experimental/client-ui-voice-input/src/client/mount.ts create mode 100644 packages/experimental/client-ui-voice-input/src/client/readiness.ts create mode 100644 packages/experimental/client-ui-voice-input/src/css-modules.d.ts create mode 100644 packages/experimental/client-ui-voice-input/src/index.ts create mode 100644 packages/experimental/client-ui-voice-input/tests/audio.client.spec.ts create mode 100644 packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts create mode 100644 packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx create mode 100644 packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts create mode 100644 packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx create mode 100644 packages/experimental/client-ui-voice-input/tests/waveform.client.spec.tsx create mode 100644 packages/experimental/client-ui-voice-input/tsconfig.json create mode 100644 packages/experimental/client-ui-voice-input/tsdown.config.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/README.i18n.yaml create mode 100644 packages/experimental/speech-to-text-sensevoice/README.md create mode 100644 packages/experimental/speech-to-text-sensevoice/README.zh.md create mode 100644 packages/experimental/speech-to-text-sensevoice/package.json create mode 100644 packages/experimental/speech-to-text-sensevoice/runtime/assets.json create mode 100644 packages/experimental/speech-to-text-sensevoice/src/config.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/index.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/inference.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/process-server.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/recognizer.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/runtime.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/src/worker.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/inference.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/installer.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/local.e2e.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/process-server.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/protocol.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/provider.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/runtime.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/worker-source.fixture.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/worker.fixture.mjs create mode 100644 packages/experimental/speech-to-text-sensevoice/tests/worker.spec.ts create mode 100644 packages/experimental/speech-to-text-sensevoice/tsconfig.json create mode 100644 packages/experimental/speech-to-text-sensevoice/tsdown.config.ts create mode 100644 packages/experimental/speech-to-text/README.i18n.yaml create mode 100644 packages/experimental/speech-to-text/README.md create mode 100644 packages/experimental/speech-to-text/README.zh.md create mode 100644 packages/experimental/speech-to-text/package.json create mode 100644 packages/experimental/speech-to-text/src/index.ts create mode 100644 packages/experimental/speech-to-text/src/types.ts create mode 100644 packages/experimental/speech-to-text/src/wave.ts create mode 100644 packages/experimental/speech-to-text/tests/service.spec.ts create mode 100644 packages/experimental/speech-to-text/tsconfig.json create mode 100644 packages/experimental/speech-to-text/tsdown.config.ts create mode 100644 packages/experimental/voice-input-bundle/README.i18n.yaml create mode 100644 packages/experimental/voice-input-bundle/README.md create mode 100644 packages/experimental/voice-input-bundle/README.zh.md create mode 100644 packages/experimental/voice-input-bundle/cordis.patch.yml create mode 100644 packages/experimental/voice-input-bundle/package.json create mode 100644 packages/experimental/voice-input-bundle/src/index.ts create mode 100644 packages/experimental/voice-input-bundle/tsconfig.json create mode 100644 packages/experimental/voice-input-bundle/tsdown.config.ts create mode 100644 snapshots/web/voice-input/recording.expected.md create mode 100644 snapshots/web/voice-input/snapshot.yml create mode 100644 snapshots/web/voice-input/ui.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml new file mode 100644 index 0000000000..ec0c24bf9c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml @@ -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: f385f4dc2d872f467aace6a07b09a11acdf8ab9f +2026-09-16-experimental-voice-input.zh.md: 15e55efe33905487f660f1373bc9c425378d9e46 diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md new file mode 100644 index 0000000000..f385f4dc2d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md @@ -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. + +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. 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. Package status uses `plugins.bundle.status` and details use the upstream `plugins.bundle.config`, both keyed by package name. A single composer activity Slot expands the voice controls without replacing the draft editor. 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. diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md new file mode 100644 index 0000000000..15e55efe33 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md @@ -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 和释放插件会使迟到结果失效并释放录音资源。 + +SenseVoice ONNX 与 Silero VAD 在一个托管 CPU 子进程中运行。原生 sherpa-onnx 包包含 ONNX Runtime;显式准备只下载并校验模型;后续调用复用预热模型,直到配置的空闲截止时间。禁用 Provider 等待其任务和进程退出,同时保留可复用的磁盘资源。浏览器原生录音与重采样避免增加音频转换可执行文件。带认证的私有环回协议传输完整 PCM 录音,取消时可以终止工作进程,而不与另一请求共享长时间推理。 + +Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。包状态使用 `plugins.bundle.status`,详情使用上游 `plugins.bundle.config`,均以包名为 key。单个输入框活动 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 观测不构成峰值内存上限。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d4838dfe32..3b1f408f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -109,6 +109,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 | diff --git a/apps/cli/package.json b/apps/cli/package.json index 5204f55a29..1945ba8424 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -105,7 +105,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", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index de2415f74b..6755dce11c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 393f41f82ed20fbc71b1659cd2417a8d7f9b5013 -README.zh.md: e5bdb032b47795dc28848561f08f0464f83e49d3 +README.md: 179992c17a3d93dd0bcb65567dd4bb6748306460 +README.zh.md: 0357908160f88566bff9d760905b4522d9a73d05 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 393f41f82e..179992c17a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -8,6 +8,8 @@ Desktop’s local native directory flow opens an Electron folder dialog attached Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. Native speech libraries remain outside ASAR and use the existing runtime signing path; recognition runs in a managed Node-mode child. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e5bdb032b4..0357908160 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -8,6 +8,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。原生语音库保留在 ASAR 外并使用既有运行时签名路径;识别在托管的 Node 模式子进程中执行。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 3aefaec12f..01dcbd0e99 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -15,6 +15,7 @@ export interface DesktopElectronBuilderConfig { readonly asarUnpack: readonly string[] readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }] readonly mac: { + readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ca846818b6..98d41b64a9 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -102,6 +102,7 @@ export function createElectronBuilderConfig( identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' }, // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], notarize: true, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4eb65c0521..e82289da82 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' +import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' @@ -289,6 +290,7 @@ async function main(): Promise { }) installDesktopDirectoryPicker(() => mainWindow) + installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) diff --git a/apps/desktop/src/microphone-permissions.ts b/apps/desktop/src/microphone-permissions.ts new file mode 100644 index 0000000000..ba644ac80f --- /dev/null +++ b/apps/desktop/src/microphone-permissions.ts @@ -0,0 +1,31 @@ +/** Microphone access belongs to the primary application frame and the operating system. */ +import { systemPreferences, type Session, type WebContents } from 'electron' + +function applicationFrame(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'dsh-app:' && parsed.hostname === 'app' + } catch (_error) { return false /* Invalid frame URLs cannot request microphone access. */ } +} + +/** + * Handle microphone requests from the owned application window; other permissions retain Electron's defaults. + * @param session - application's browser session. + * @param primary - current primary window contents, absent while no window is open. + */ +export function installMicrophonePermissions(session: Session, primary: () => WebContents | undefined): void { + session.setPermissionCheckHandler((contents, permission, origin, details) => { + if (permission !== 'media') return true + return contents != null && contents === primary() && details.isMainFrame + && applicationFrame(origin) && details.mediaType === 'audio' + && (process.platform !== 'darwin' || systemPreferences.getMediaAccessStatus('microphone') === 'granted') + }) + session.setPermissionRequestHandler((contents, permission, callback, details) => { + if (permission !== 'media') { callback(true); return } + const allowed = contents === primary() && details.isMainFrame && applicationFrame(details.requestingUrl) + && 'mediaTypes' in details && details.mediaTypes.length === 1 && details.mediaTypes[0] === 'audio' + if (!allowed) { callback(false); return } + if (process.platform !== 'darwin') { callback(true); return } + void systemPreferences.askForMediaAccess('microphone').then(callback, () => { callback(false) }) + }) +} diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 3286f23e89..514e150ab5 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -40,6 +40,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') + expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone') expect(config.extraResources).toHaveLength(1) expect(config.extraResources[0]?.to).toBe('runtime') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 4856b0a5cb..96e1e1d5fb 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -135,7 +135,8 @@ vi.mock('electron', () => ({ handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) }, }, Menu: harness.menu, - session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, + session: { defaultSession: { setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), + webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, })) vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) diff --git a/apps/desktop/tests/microphone-permissions.spec.ts b/apps/desktop/tests/microphone-permissions.spec.ts new file mode 100644 index 0000000000..9cdd416481 --- /dev/null +++ b/apps/desktop/tests/microphone-permissions.spec.ts @@ -0,0 +1,51 @@ +/** Desktop media permissions exclude external pages, subframes and camera requests. */ +import type { Session, WebContents } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installMicrophonePermissions } from '../src/microphone-permissions.ts' + +const access = vi.hoisted(() => ({ getMediaAccessStatus: vi.fn(() => 'granted'), askForMediaAccess: vi.fn(async () => true) })) +vi.mock('electron', () => ({ systemPreferences: access })) +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) +function fixture(platform: NodeJS.Platform = 'darwin') { + vi.stubGlobal('process', { ...process, platform }) + const setPermissionCheckHandler = vi.fn() + const setPermissionRequestHandler = vi.fn() + const primary = {} as WebContents + installMicrophonePermissions({ setPermissionCheckHandler, setPermissionRequestHandler } as unknown as Session, () => primary) + return { primary, check: setPermissionCheckHandler.mock.calls[0]![0]!, request: setPermissionRequestHandler.mock.calls[0]![0]! } +} +it('allows only audio checks from the owned primary application frame', () => { + const f = fixture(), details = { isMainFrame: true, mediaType: 'audio' as const } + expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(true) + expect(f.check(null, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check({} as WebContents, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check(f.primary, 'media', 'https://example.com', details)).toBe(false) + expect(f.check(f.primary, 'media', 'invalid', details)).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, isMainFrame: false })).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, mediaType: 'video' })).toBe(false) + access.getMediaAccessStatus.mockReturnValueOnce('denied') + expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check(f.primary, 'clipboard-sanitized-write', 'dsh-app://app', details)).toBe(true) +}) +it('requests macOS microphone access and reports operating-system rejection', async () => { + const f = fixture(), done = vi.fn(), details = { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] } + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(true) }) + expect(access.askForMediaAccess).toHaveBeenCalledWith('microphone') + access.askForMediaAccess.mockRejectedValueOnce(new Error('permission denied')) + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(false) }) + for (const denied of [{ ...details, mediaTypes: ['audio', 'video'] }, { ...details, mediaTypes: [] }, + { ...details, isMainFrame: false }, { isMainFrame: true, requestingUrl: 'dsh-app://app/' }]) { + f.request(f.primary, 'media', done, denied); expect(done).toHaveBeenLastCalledWith(false) + } + f.request(f.primary, 'clipboard-sanitized-write', done, details) + expect(done).toHaveBeenLastCalledWith(true) +}) +it.each(['win32', 'linux'] as const)('leaves system authorization to Chromium on %s', (platform) => { + const f = fixture(platform), done = vi.fn() + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(true) + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(done).toHaveBeenCalledWith(true) + expect(access.askForMediaAccess).not.toHaveBeenCalled() +}) diff --git a/apps/web/package.json b/apps/web/package.json index acdb74244a..a4858c3199 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,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:^" } } diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f807eb83c6..8a83895643 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -305,8 +305,8 @@ export interface LaunchOptions { profileResolutionMode?: Extract /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot - /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ - compareReplaySession?: boolean + /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ + compareReplaySession?: boolean | 'read-only' /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -920,7 +920,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + 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' }, + 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.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() + expect(messages).toBe(1) + expect(tripwire.pageErrors).toEqual([]) +}, 120_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 83b574f9bf..880d27e739 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,6 +25,7 @@ "tests/default-product-isolation.e2e.ts", "tests/diff-context.e2e.ts", "tests/scaffold.ts", + "tests/voice-input.e2e.ts", "tests/auto-review-fixture.ts", "tests/scaffold-generation.spec.ts", "tests/scaffold-hermetic.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 7157fab563..f59bbdc570 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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: df39022715cf1000daca9fdeaf668e9cedf88bb1 -capability-seams.zh.md: fc9c8fd46375d77fb42fcc6cf0aef160527d100e +capability-seams.md: c97c2d97b9555f74bbb0151a46116da177eb5a8b +capability-seams.zh.md: c825f666b0466d52c82caab5212e88e5af1d2f9a diff --git a/docs/capability-seams.md b/docs/capability-seams.md index df39022715..c97c2d97b9 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -57,6 +57,8 @@ flowchart LR pkg_subagent_in_process_driver["subagent-in-process-driver"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] + svc_speechController["ctx.speechController
Experimental transcription Remote"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] @@ -207,6 +209,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
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
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] @@ -284,12 +289,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 @@ -475,6 +483,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 @@ -540,6 +549,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-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`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. | @@ -594,6 +604,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 implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `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-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index fc9c8fd463..c825f666b0 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -59,6 +59,8 @@ flowchart LR pkg_subagent_in_process_driver["subagent-in-process-driver"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] + svc_speechController["ctx.speechController
Experimental transcription Remote"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] @@ -209,6 +211,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
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
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] @@ -286,12 +291,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 @@ -477,6 +485,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 @@ -542,6 +551,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-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`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。 | @@ -596,6 +606,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 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | +| `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-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c049eab040..f576e2aea7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: c3e84ba47ad687915ae1c641e8600bd43a2102b6 -config-catalog.zh.md: b8b86030d96ebd8ea49cc6e545007a0c319720f3 +config-catalog.md: fe01cf82f37e94bb6d2709e8991652e252ebc196 +config-catalog.zh.md: 02d732455384e90c24a00869f2cde6d5d3a066ee diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c3e84ba47a..fe01cf82f3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,6 +572,24 @@ export interface Config { Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) + + +## `@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) + ## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp` @@ -802,6 +820,76 @@ export interface Config { Source: [`packages/experimental/ptc-runtime-python/src/index.ts:42`](../packages/experimental/ptc-runtime-python/src/index.ts) + + +## `@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) + + + +## `@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) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` @@ -3761,6 +3849,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)) @@ -3834,6 +3923,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@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-agent-team-web-profile` ([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b8b86030d9..02d7324553 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,6 +574,24 @@ export interface Config { 来源:[`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) + + +## `@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) + ## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp` @@ -804,6 +822,76 @@ export interface Config { 来源:[`packages/experimental/ptc-runtime-python/src/index.ts:42`](../packages/experimental/ptc-runtime-python/src/index.ts) + + +## `@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) + + + +## `@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) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` @@ -3763,6 +3851,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)) @@ -3835,6 +3924,7 @@ export interface Config { - `@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-agent-team-web-profile`([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-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)) diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 92e23f39f1..212654acc1 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -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: b4d6aa226979525ef658f9cf1d33d5ca3e59dcf6 -README.zh.md: 9134fcf37789911aec05dcdc9e9c3dc036a33873 +README.md: 34522c050a224fa5dae6ef78a324125adef59f72 +README.zh.md: d5a132f203cb16810a55c3b699a7a2fb2f37d217 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index b4d6aa2269..34522c050a 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -45,6 +45,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` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 9134fcf377..d5a132f203 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -45,6 +45,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` | diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..30df105998 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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/slots.md -slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363 -slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668 +slots.md: ff1d69c08f1ff5093135d4a979eb86ae352147a4 +slots.zh.md: fd9ad5bc8c49febe9e39ba6ab19abd31a4a7d697 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..ff1d69c08f 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -6,6 +6,8 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md). +`plugins.bundle.status` is keyed by npm package name and renders short status before the bundle switch; `plugins.bundle.config` supplies bundle detail configuration. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. + ## Declaration and lifecycle `SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..fd9ad5bc8c 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -6,6 +6,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。 +`plugins.bundle.status` 以 npm 包名为 key,在 Bundle 卡片开关前显示简短状态;`plugins.bundle.config` 提供 Bundle 详情配置。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 + ## 声明与生命周期 `SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。 diff --git a/docs/subsystems/voice-input.i18n.yaml b/docs/subsystems/voice-input.i18n.yaml new file mode 100644 index 0000000000..bde639522c --- /dev/null +++ b/docs/subsystems/voice-input.i18n.yaml @@ -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: d72bf01a372413fbc33a93a8a3a77f5152a3807b +voice-input.zh.md: c45275516166820f9dfac2d7d96c3a43b70d72d8 diff --git a/docs/subsystems/voice-input.md b/docs/subsystems/voice-input.md new file mode 100644 index 0000000000..d72bf01a37 --- /dev/null +++ b/docs/subsystems/voice-input.md @@ -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 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 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, plugin status, 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 store recognition preferences, and `plugins.bundle.status` displays short readiness beside the enable 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. + + + + + +## 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). + + + +### `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 + +/** + * Persist the user's recognition preferences. + * @param patch - changed preference fields. + * @returns after preferences are saved. + */ +@Remote configure(patch: SpeechSelectionPatch): Promise + +/** + * 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 + +/** + * 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 +``` + +Source: [`packages/experimental/api-speech-to-text/src/index.ts`](../../packages/experimental/api-speech-to-text/src/index.ts) + + + +### `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 + +/** + * 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 + +/** + * Read provider readiness and current user preferences together. + * @returns one detached complete observation. + */ +snapshot(): SpeechSnapshot + +/** + * Persist changed preference fields in the ordinary user-settings document. + * @param patch - explicit provider or language changes. + * @returns after persistence and the resolved preference update. + */ +async configure(patch: SpeechSelectionPatch): Promise + +/** + * 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 + +/** + * Apply composition defaults and capture the selected provider. Missing providers 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 +``` + +Source: [`packages/experimental/speech-to-text/src/index.ts`](../../packages/experimental/speech-to-text/src/index.ts) + diff --git a/docs/subsystems/voice-input.zh.md b/docs/subsystems/voice-input.zh.md new file mode 100644 index 0000000000..c452755161 --- /dev/null +++ b/docs/subsystems/voice-input.zh.md @@ -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` 携带显示名称及 `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 在输入框、插件状态、详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。 + +麦克风占用模型选择器与发送按钮之间的 `conversation.input.activity`。点击开始录音并展开工具栏;停止后转写并插入草稿。活动栏保留编辑器与提交按钮,拥有局部反馈,并在卸载时释放展开状态。取消、Escape 或隐藏页面会丢弃录音。波形历史展示实测麦克风音量。Bundle 详情保存识别偏好,`plugins.bundle.status` 在启用开关旁显示简短准备状态。 + +## 设计依据 + +[语音输入决策](../../.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md)解释临时音频、显式 Provider 选择和延迟准备本地运行时。 + + + + + +## 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). + + + +### `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 + +/** + * Persist the user's recognition preferences. + * @param patch - changed preference fields. + * @returns after preferences are saved. + */ +@Remote configure(patch: SpeechSelectionPatch): Promise + +/** + * 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 + +/** + * 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 +``` + +Source: [`packages/experimental/api-speech-to-text/src/index.ts`](../../packages/experimental/api-speech-to-text/src/index.ts) + + + +### `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 + +/** + * 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 + +/** + * Read provider readiness and current user preferences together. + * @returns one detached complete observation. + */ +snapshot(): SpeechSnapshot + +/** + * Persist changed preference fields in the ordinary user-settings document. + * @param patch - explicit provider or language changes. + * @returns after persistence and the resolved preference update. + */ +async configure(patch: SpeechSelectionPatch): Promise + +/** + * 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 + +/** + * Apply composition defaults and capture the selected provider. Missing providers 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 +``` + +Source: [`packages/experimental/speech-to-text/src/index.ts`](../../packages/experimental/speech-to-text/src/index.ts) + diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index d187f3c10a..71a8da853c 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -165,6 +165,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', '@deepseek-ai/dsh-experimental-agent-team-web-profile', ] diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 3d3711a77a..e195dd6533 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -282,6 +282,8 @@ describe('ImageGallery', () => { useTrajectory, useInput, inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: vi.fn(), addAttachments: vi.fn(() => true), removeAttachment: vi.fn(), diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 8a63203668..546c13c7ff 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -392,6 +392,8 @@ function makeHarness( }, useInput: (() => { throw new Error('unused') }), inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6f234d0b92..1217ad6dd9 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 749c2d0d99ce7e5afe2f9a03b1451d62a17206e7 -README.zh.md: 98d7d9672922ca68ea4df5499f8789e8c2fde162 +README.md: 83fcfbb527e631e25bddb532fa60d4def0ef33f0 +README.zh.md: 80f7df87e3277729e37f573371cf3d2b21dff721 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 749c2d0d99..83fcfbb527 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -114,6 +114,10 @@ try { The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. +`InputActions.captureInsertion()` captures the draft selection and revision; `insertText(text, span)` inserts one undoable plain-text edit only while that revision is current and the editor permits editing. Asynchronous consumers retain rejected results for user action. + +`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls while preserving the editor and submit action. The occupant releases expansion on unmount and owns any activity-specific feedback. + ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 98d7d96729..80f7df87e3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -114,6 +114,10 @@ try { selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core 包声明。 +`InputActions.captureInsertion()` 捕获草稿选区与版本;`insertText(text, span)` 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。 + +`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件,同时保留编辑器和提交按钮。占用者在卸载时释放展开状态,并拥有活动专属反馈。 + ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 1402639dac..27e96749fd 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -323,6 +323,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'conversation.input.activity': { kind: 'single', scope: 'session' }, 'conversation.composer.dock': { kind: 'list', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index b4cd6b9bdf..4036736374 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -220,6 +220,15 @@ export interface SessionInputResolver { * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { + /** @returns a revision-guarded copy of the current editor selection. */ + captureInsertion(): TokenSpan + /** + * Insert asynchronous text without replacing subsequent edits or reference chips. + * @param text - plain text to insert in one undo step. + * @param span - captured selection and draft revision. + * @returns false when the draft changed or submission locked the editor. + */ + insertText(text: string, span: TokenSpan): boolean /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index cccd6a0fe6..b1ef334cb5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -183,6 +183,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.left': { kind: 'list'; scope: 'session' } /** Compact controls before the composer submit action. */ 'conversation.input.right': { kind: 'list'; scope: 'session' } + /** Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action. */ + 'conversation.input.activity': { kind: 'single'; scope: 'session'; owner: InputActivityOwnerProps } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } /** Optional draft-attachment rail and drop target. */ @@ -351,6 +353,12 @@ export interface InputControlOwnerProps { locked: boolean } +/** A toolbar activity hides ordinary accessory controls while expanded; its occupant must release expansion on unmount. */ +export interface InputActivityOwnerProps extends InputControlOwnerProps { + /** @param active - whether the occupant needs the toolbar width before the submit action. */ + onActiveChange: (active: boolean) => void +} + /** Full props of the resident composer bar. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> @@ -358,7 +366,7 @@ export type ComposerBarProps = | 'conversation.input.attachments' | 'conversation.input.overlay' | 'conversation.input.permission' | 'conversation.input.left' | 'conversation.input.plan' - | 'conversation.input.right' | 'conversation.input.model' + | 'conversation.input.right' | 'conversation.input.model' | 'conversation.input.activity' | 'conversation.composer.dock' > & InjectFace diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 2054624342..ca6047c4ec 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -210,6 +210,19 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an asynchronous text result as one independent undo operation. + * @param span - owner-validated insertion range. + * @param text - text sanitized with the same rules as paste. + * @returns whether the range mapped and the edit applied. + */ + insertAsyncText(span: DetectSpan, text: string): boolean { + let applied = false + const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') + this.applyEdit(() => { applied = $replaceDetectSpanWithText(span, clean) }, PASTE_TAG) + return applied + } + /** * Insert a reference chip with the existing trailing-space rule. * @param span - detect-coordinate range. diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..8931d1c4d0 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -117,6 +117,12 @@ export class SessionInputShell implements SessionInput { } /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { + captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), + insertText: (text, span) => { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false + if (span.draftRev !== this.rev) return false + return this.draftEditor.insertAsyncText(span, text) + }, setDraft: (text) => { this.setDraft(text) }, addAttachments: ids => this.addAttachments(ids), removeAttachment: (id) => { this.removeAttachment(id) }, diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6369e5728d..4e6764a14a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -392,3 +392,10 @@ font-size: 12px; cursor: pointer; } + +.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; } +.tools[hidden], .standardControls[hidden] { display: none; } +.trailingActive { flex: 1; margin-left: 0; } +.activity { display: flex; align-items: center; } +.activity:empty { display: none; } +.activityExpanded { flex: 1; min-width: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 283f50c456..9349a58b48 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -55,6 +55,8 @@ export const InputBar = memo(function InputBar({ const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') + const [activity, setActivity] = useState(false) + useEffect(() => { setActivity(false) }, [sessionId]) const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const subagent = useSession(s => s.subagent) ?? null @@ -406,7 +408,7 @@ export const InputBar = memo(function InputBar({ showPlaceholder={draft === '' && attachments.length === 0 && !claimActive} />
-
+ {input === undefined || sessionId === undefined ? null :
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} +
{interruptible && ( , + }) + const meter = view.getByRole('button', { name: '上下文已用 25%' }) + fireEvent.click(view.getByRole('button', { name: 'microphone' })) + expect(view.getByRole('button', { name: '上下文已用 25%' })).toBe(meter) + fireEvent.click(meter) + expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b7ec25c601..379cfb3c3b 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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-plugin-manager/README.md -README.md: f50289840f9dda3bb34252480803cb3b0a3b680c -README.zh.md: 4e812870571d33c6b65d3c04a7f000f7754df97f +README.md: 1be8aaf8aed93dcfef527f373300f5cfb49c9bb6 +README.zh.md: eac6512b91916302e0d265045365d0ad8e890c82 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index f50289840f..1be8aaf8ae 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -79,7 +79,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. -`plugins.bundle.status` is keyed by npm package name and renders short status before the bundle switch; `plugins.bundle.config` supplies bundle detail configuration. +`plugins.bundle.status` is keyed by npm package name and renders short status before the bundle switch; `plugins.bundle.config` supplies bundle detail configuration. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 4e81287057..eac6512b91 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -79,7 +79,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ 页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 -`plugins.bundle.status` 以 npm 包名为 key,在 Bundle 卡片开关前显示简短状态;`plugins.bundle.config` 提供 Bundle 详情配置。 +`plugins.bundle.status` 以 npm 包名为 key,在 Bundle 卡片开关前显示简短状态;`plugins.bundle.config` 提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 1e063fe84b..3a94095741 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -33,7 +33,7 @@ import css from './PluginManagerPage.module.css' export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> - & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.status'> + & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.status' | 'plugins.bundle.activation'> & InjectFace /** The page's slot renderer, narrowed to the configuration slots. */ @@ -834,6 +834,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const ledger = props.useConfigLedger(snapshot => snapshot) // What is open; a package that leaves the list (uninstalled) drops back to the cards. const [view, setView] = useState({ kind: 'list' }) + const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) // A package an install just enabled: scroll it into view and mark it for a moment. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -857,6 +858,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined + const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name)) const setRowEnabled = (row: PackageRow, enabled: boolean): void => { /* v8 ignore next -- a row without a live entry has its switch disabled */ if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) @@ -873,8 +875,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { busy={state.busy.includes(pkg.name)} highlighted={state.highlight === pkg.name} statusContent={renderSlot('plugins.bundle.status', { packageName: pkg.name, enabled: pkg.enabled }, { entryKey: pkg.name })} - onOpen={() => { setView({ kind: 'package', name: pkg.name }) }} - onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name }) }} + onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }} /> ) // The Official group: the bundles the installation ships, then the plugins that registered their configuration. @@ -977,6 +979,12 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} + {showsCards && activated !== undefined && !state.install.open + ? renderSlot('plugins.bundle.activation', { + packageName: activated.name, + onDismiss: () => { setActivation(null) }, + onOpenDetails: () => { setActivation(null); setView({ kind: 'package', name: activated.name }) }, + }, { entryKey: activated.name }) : null} { setActivation(state.install.installed); props.enableInstalled() }} onApproveBuilds={props.approveBuildsAndRetry} /> {state.confirm === null diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index f5d9061f3c..102a73a3d7 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -31,7 +31,7 @@ export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' -export type { PluginConfigViewProps, PluginPackageOwnerProps } from './slot-contract.ts' +export type { PluginConfigViewProps, PluginPackageOwnerProps, PluginActivationOwnerProps } from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -88,6 +88,7 @@ export function apply(ctx: ClientContext): void { children: { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.status': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, }, diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index fb3d4bfe6e..9fd4fec14b 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -24,10 +24,21 @@ export interface PluginPackageOwnerProps { readonly enabled: boolean } +/** One user-requested bundle activation and navigation to its configuration page. */ +export interface PluginActivationOwnerProps { + readonly packageName: string + /** Dismiss guidance for this activation. */ + readonly onDismiss: () => void + /** Dismiss guidance and open this bundle's detail page. */ + readonly onOpenDetails: () => void +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** Short readiness beside the bundle switch, keyed by npm package name. */ 'plugins.bundle.status': { kind: 'keyed'; scope: 'root'; owner: PluginPackageOwnerProps } + /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ + 'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps } /** * One official plugin the Plugins page lists in its Official group after * the official bundles: `label` is the card's title and `order` its place. diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 3adce8a6ed..12f1019827 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -10,6 +10,7 @@ import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' +import type { PluginActivationOwnerProps } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -53,7 +54,7 @@ const READY: PluginManagerState = { } /** Configuration entries a test supplies: what each slot cell renders, by `:` and the view asked for. */ -type SlotBodies = Record ReactNode> +type SlotBodies = Record ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } @@ -86,7 +87,7 @@ function renderTab(state: Partial = {}, config: Partial - bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view) ?? null, + bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view, owner) ?? null, } as unknown as PluginManagerPageProps const { rerender } = render() return { @@ -711,3 +712,41 @@ it('places bundle readiness immediately before its enable switch', () => { expect(status.parentElement).toBe(toggle.parentElement) expect(status.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) + +it('offers bundle-owned guidance only after explicit enablement and navigates to its detail page', () => { + const name = 'dsh-better-sidebar' + const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, { + [`plugins.bundle.activation:${name}`]: (_view, owner) => , + [`plugins.bundle.config:${name}`]: () =>
Bundle setup
, + }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg()] }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg({ enabled: false })] }) + fireEvent.click(screen.getByRole('switch')) + expect(actions.setEnabled).toHaveBeenCalledWith(name, true) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg()], busy: [name] }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ busy: [] }) + fireEvent.click(screen.getByText('Go to setup')) + expect(screen.getByText('Bundle setup')).toBeTruthy() + expect(screen.queryByText('Go to setup')).toBeNull() +}) + +it('dismisses activation guidance until the user enables the bundle again', () => { + const name = 'dsh-better-sidebar' + const { set } = renderTab({ packages: [pkg({ enabled: false })] }, {}, { + [`plugins.bundle.activation:${name}`]: (_view, owner) => , + }) + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg()] }) + fireEvent.click(screen.getByText('Later')) + set({ packages: [pkg()] }) + expect(screen.queryByText('Later')).toBeNull() + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg({ enabled: false })] }) + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg()] }) + expect(screen.getByText('Later')).toBeTruthy() +}) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index f6ffe92804..ff5188a628 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -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/experimental/client-ui-voice-input/README.md -README.md: b8e4ef32158b577dade8665362e30138644eca42 -README.zh.md: aedb37794db837f5b3a4a5ae971b4e21a01066ff +README.md: f6a7c2ba0e8567205ddd093b85915a487c3da898 +README.zh.md: 42657a6f0d73d61caefb12c83e7d73d7408d52cb diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index b8e4ef3215..f6a7c2ba0e 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -25,7 +25,7 @@ This optional browser plugin adds a microphone between the model selector and Se ## Use this package -Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins, open its details and choose Download and prepare. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. +Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index aedb37794d..42657a6f0d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md),打开详情并点击“下载并准备”。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 +在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 4de6cd70df..4d6057be8b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -129,7 +129,7 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, } } if (!expanded) return return
+ + }> +

{t('setupPrompt.body')}

+ +} diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index 785ab3c278..918652da79 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -5,6 +5,11 @@ export const NS = 'voice-input' /** Chinese dictionary and key source. */ export const zh = { + dictate: '听写', + 'setupPrompt.title': '使用语音输入前需要安装', + 'setupPrompt.body': '语音输入插件已开启。首次使用需要在本机下载并准备识别模型,请前往插件详情页查看空间、内存和时间说明,再开始安装。', + 'setupPrompt.later': '稍后', + 'setupPrompt.open': '前往安装', 'setup.local': '将在运行 DSH 的机器上下载本地模型,无需安装 Python 或编译工具。', 'setup.disk': '硬盘空间', 'setup.diskValue': '建议预留约 {gb} GB,包含模型、运行时和下载缓存', @@ -81,6 +86,11 @@ export type VoiceKey = keyof typeof zh /** English dictionary with the same complete key set. */ export const en: Record = { + dictate: 'Dictate', + 'setupPrompt.title': 'Set up voice input before recording', + 'setupPrompt.body': '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.', + 'setupPrompt.later': 'Later', + 'setupPrompt.open': 'Go to setup', 'setup.local': 'Local models will be downloaded to the machine running DSH. No Python or compiler is required.', 'setup.disk': 'Disk space', 'setup.diskValue': 'Allow about {gb} GB for models, runtime, and download caches', diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index 7e617324d9..3af8f6d451 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -12,6 +12,7 @@ import { en, NS, zh } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import { observeReadiness } from './readiness.ts' import { VoicePreparation, VoiceStatus } from './PreparationCard.tsx' +import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' export const inject = ['remote', 'slots', 'locale'] @@ -44,6 +45,9 @@ function registerUi(ctx: Context): void { ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: '@deepseek-ai/dsh-experimental-voice-input-bundle', locale: NS, inject: () => actions, }, VoicePreparation)) + ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({ name: 'plugins.bundle.activation', + key: '@deepseek-ai/dsh-experimental-voice-input-bundle', locale: NS, inject: () => actions, + }, VoiceSetupPrompt)) } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 4660be0cca..a11f6d7eac 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -31,6 +31,7 @@ async function fixture(fail = false) { ctx.slots.register({ name: 'root', children: { 'conversation.input.activity': { kind: 'single', scope: 'session' }, 'plugins.bundle.status': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, } } as never, () => null) @@ -54,7 +55,7 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a await actions.configure({ language: 'zh' }) await actions.prepare('local' as SpeechProviderId) await actions.cancelPreparation('local' as SpeechProviderId) - for (const slot of ['plugins.bundle.status', 'plugins.bundle.config'] as const) { + for (const slot of ['plugins.bundle.status', 'plugins.bundle.config', 'plugins.bundle.activation'] as const) { const item = b.ctx.slots.entries(slot)[0]! expect(item.locale).toBe('voice-input') const injected = (item.inject as unknown as () => VoiceInputInjected & { compact?: boolean })() @@ -74,6 +75,7 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a await fiber.dispose() expect(dispose).toHaveBeenCalledOnce() expect(b.ctx.slots.entries('conversation.input.activity')).toHaveLength(0) + expect(b.ctx.slots.entries('plugins.bundle.activation')).toHaveLength(0) expect(b.unmount).toHaveBeenCalledOnce() } finally { await b.ctx.fiber.dispose() } }) diff --git a/packages/experimental/client-ui-voice-input/tests/setup-prompt.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/setup-prompt.client.spec.tsx new file mode 100644 index 0000000000..cb66648dfe --- /dev/null +++ b/packages/experimental/client-ui-voice-input/tests/setup-prompt.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/** Activation guidance waits for cache inspection and leaves installation to the detail page. */ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SpeechPreparationState, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import { afterEach, expect, it, vi } from 'vitest' +import { VoiceSetupPrompt, type VoiceSetupPromptProps } from '../src/client/VoiceSetupPrompt.tsx' +import type { SpeechReadiness } from '../src/client/readiness.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +function fixture() { + const state = createSnapshotStore({ catalog: null, connected: false, error: null }) + const onDismiss = vi.fn(), onOpenDetails = vi.fn(), providerId = 'local' as SpeechProviderId + const props = { packageName: 'voice-bundle', t: makeTranslate(zh), onDismiss, onOpenDetails, + useSpeechReadiness: bindSnapshotSelector(state) } as VoiceSetupPromptProps + render() + return { onDismiss, onOpenDetails, publish(preparation: SpeechPreparationState, location: 'host-local' | 'cloud' = 'host-local') { + act(() => { state.set({ connected: true, error: null, catalog: { + selection: { providerId, language: 'auto' }, maxAudioBytes: 1000, maxDurationSeconds: 120, + providers: [{ id: providerId, name: 'SenseVoice', location, preparation }], + } }) }) + } } +} + +it('waits for inspection, then offers the installation page without starting preparation', () => { + const b = fixture() + expect(screen.queryByText(zh['setupPrompt.title'])).toBeNull() + b.publish({ phase: 'checking', step: 'check', startedAt: 0 }) + expect(screen.queryByText(zh['setupPrompt.title'])).toBeNull() + expect(b.onDismiss).not.toHaveBeenCalled() + b.publish({ phase: 'unprepared' }) + expect(screen.getByText(zh['setupPrompt.body'])).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.open'] })) + expect(b.onOpenDetails).toHaveBeenCalledOnce() + expect(b.onDismiss).not.toHaveBeenCalled() +}) + +it('allows postponing installation while leaving the plugin enabled', () => { + const b = fixture() + b.publish({ phase: 'unprepared' }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.later'] })) + expect(b.onDismiss).toHaveBeenCalledOnce() + expect(b.onOpenDetails).not.toHaveBeenCalled() +}) + +it.each(['standby', 'ready', 'failed'] as const)('does not offer installation for %s resources', (phase) => { + const b = fixture() + b.publish(phase === 'failed' ? { phase, message: 'cache unreadable' } : { phase }) + expect(screen.queryByText(zh['setupPrompt.title'])).toBeNull() + expect(b.onDismiss).toHaveBeenCalledOnce() +}) + +it('does not describe cloud-provider setup as a local model installation', () => { + const b = fixture() + b.publish({ phase: 'unprepared' }, 'cloud') + expect(screen.queryByText(zh['setupPrompt.title'])).toBeNull() + expect(b.onDismiss).toHaveBeenCalledOnce() +}) diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index d1ae8fe14a..9bb28bcc89 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -43,6 +43,7 @@ function stop(): void { fireEvent.click(screen.getByRole('button', { name: zh.st it('clicks to record, shows measured audio, and stops to insert without sending or a popup', async () => { const b = fixture() + expect(screen.getByRole('button', { name: zh.start }).getAttribute('title')).toBe('听写') fireEvent.mouseDown(screen.getByRole('button', { name: zh.start })) await start() expect(b.props.onActiveChange).toHaveBeenLastCalledWith(true) diff --git a/packages/experimental/speech-to-text-sensevoice/README.i18n.yaml b/packages/experimental/speech-to-text-sensevoice/README.i18n.yaml index 8f15679ab0..740410587c 100644 --- a/packages/experimental/speech-to-text-sensevoice/README.i18n.yaml +++ b/packages/experimental/speech-to-text-sensevoice/README.i18n.yaml @@ -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/experimental/speech-to-text-sensevoice/README.md -README.md: f8fe45e2208aa0235811182173abfe1418ab28b3 -README.zh.md: d1775798847fb2ee4be8fc92c71f1c3f600facd9 +README.md: 62912955502c16504838a91e2dc866d31d7170ff +README.zh.md: 9251ac78107d7003d7aa47a8af5616685592d684 diff --git a/packages/experimental/speech-to-text-sensevoice/README.md b/packages/experimental/speech-to-text-sensevoice/README.md index f8fe45e220..6291295550 100644 --- a/packages/experimental/speech-to-text-sensevoice/README.md +++ b/packages/experimental/speech-to-text-sensevoice/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This provider recognizes speech with SenseVoiceSmall ONNX and Silero VAD on the Host CPU. The platform-specific sherpa-onnx Node package includes ONNX Runtime; users need no Python, compiler or model conversion. Activation registers the provider without loading models or downloading assets. +This provider recognizes speech with SenseVoiceSmall ONNX and Silero VAD on the Host CPU. The platform-specific sherpa-onnx Node package includes ONNX Runtime; users need no Python, compiler or model conversion. Activation checks cached resources without loading models or downloading assets. ## Table of Contents @@ -27,6 +27,8 @@ This provider recognizes speech with SenseVoiceSmall ONNX and Silero VAD on the The [bundle](../voice-input-bundle/README.md) supplies an absolute `dataRoot` under the DSH home. Preparation downloads revision-pinned model files, verifies their sizes and SHA-256 hashes, then loads them. `precision` defaults to `int8`; `fp32` selects the larger reference weights. `modelDirectory` supplies an existing absolute directory containing the selected ONNX file and `tokens.txt`; `vadModelPath` selects an existing Silero ONNX file. `modelOrigin` selects a Hugging Face-compatible download origin while preserving pinned paths and checksums. Verified completed files remain reusable after cancellation or failure. +Every activation checks the selected model, tokens and VAD on disk. Complete verified caches restore readiness immediately after inspection; the first recording wakes the worker. Missing files or mismatched managed checksums require explicit preparation, while unreadable paths report an error. Explicit deployment paths are checked for accessibility; their contents remain the deployer's responsibility. Disabling the plugin or restarting the Host does not require downloading intact caches again. + ----- diff --git a/packages/experimental/speech-to-text-sensevoice/README.zh.md b/packages/experimental/speech-to-text-sensevoice/README.zh.md index d177579884..9251ac7810 100644 --- a/packages/experimental/speech-to-text-sensevoice/README.zh.md +++ b/packages/experimental/speech-to-text-sensevoice/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -此 Provider 在 Host CPU 上使用 SenseVoiceSmall ONNX 与 Silero VAD 识别语音。各平台的 sherpa-onnx Node 包包含 ONNX Runtime;用户无需 Python、编译器或模型转换。启用只注册 Provider,不加载模型或下载资源。 +此 Provider 在 Host CPU 上使用 SenseVoiceSmall ONNX 与 Silero VAD 识别语音。各平台的 sherpa-onnx Node 包包含 ONNX Runtime;用户无需 Python、编译器或模型转换。启用时检查缓存资源,不加载模型或下载资源。 ## 目录 @@ -27,6 +27,8 @@ kind: "package-reference" [Bundle](../voice-input-bundle/README.zh.md) 在 DSH 主目录下提供绝对 `dataRoot`。准备会下载固定修订的模型文件,校验大小与 SHA-256,再加载模型。`precision` 默认为 `int8`,`fp32` 选择较大的参考权重。`modelDirectory` 提供包含所选 ONNX 文件和 `tokens.txt` 的已有绝对目录,`vadModelPath` 选择已有 Silero ONNX 文件。`modelOrigin` 选择兼容 Hugging Face 的下载源,固定路径和校验值保持不变。取消或失败后复用已完成并通过校验的文件。 +每次启用都会检查磁盘上的所选模型、词表与 VAD。缓存完整且校验通过后立即恢复就绪,首次录音再唤醒工作进程。文件缺失或托管文件校验不符时需要显式准备;路径不可读时报告错误。显式部署路径只检查可访问性,文件内容由部署者负责。禁用插件或重启 Host 后,无需重新下载完好的缓存。 + ----- diff --git a/packages/experimental/speech-to-text-sensevoice/src/index.ts b/packages/experimental/speech-to-text-sensevoice/src/index.ts index 1bc4d70bf7..5fd467f9f1 100644 --- a/packages/experimental/speech-to-text-sensevoice/src/index.ts +++ b/packages/experimental/speech-to-text-sensevoice/src/index.ts @@ -12,7 +12,7 @@ export const name = 'experimental-speech-to-text-sensevoice' export const inject = ['speechToText', 'subprocess'] /** - * Register the local recognizer under its configured provider id. + * Register the local recognizer and inspect disk caches without downloading or loading models. * @param ctx - Host registry and subprocess owner. * @param config - validated runtime configuration. */ @@ -34,6 +34,7 @@ export function apply(ctx: Context, config: Config): void { return await worker.transcribe(input, signal) }, }) + worker.inspect() return async () => { const removing = unregister() await worker.dispose() diff --git a/packages/experimental/speech-to-text-sensevoice/src/recognizer.ts b/packages/experimental/speech-to-text-sensevoice/src/recognizer.ts index 69cfd2ca56..797c86ea0e 100644 --- a/packages/experimental/speech-to-text-sensevoice/src/recognizer.ts +++ b/packages/experimental/speech-to-text-sensevoice/src/recognizer.ts @@ -7,7 +7,7 @@ import type { SpeechInput, SpeechPreparationState, SpeechPreparationStep, Speech import { deadline } from '@deepseek-ai/dsh-timeout' import { z } from 'zod' import type { Config } from './config.ts' -import { prepareRuntime, type RuntimePaths } from './runtime.ts' +import { inspectRuntime, prepareRuntime, type RuntimePaths } from './runtime.ts' const transcriptSchema = z.object({ text: z.string(), audioSeconds: z.number().nonnegative(), inferenceSeconds: z.number().nonnegative(), @@ -101,7 +101,6 @@ export class SenseVoiceWorker { private readonly lifetime = new AbortController() private idle: ReturnType | undefined private runtime: RuntimePaths | undefined - private loaded = false private state: SpeechPreparationState & { readonly steps: readonly SpeechPreparationStep[] } private readonly listeners = new Set<() => void>() private lastProgressAt = 0 @@ -133,7 +132,7 @@ export class SenseVoiceWorker { private publish(state: SpeechPreparationState): void { const previous = this.state - const steps = previous.steps.map((item): SpeechPreparationStep => { + const steps = state.steps ?? previous.steps.map((item): SpeechPreparationStep => { if (state.phase === 'ready') return { ...item, status: 'complete' } if (state.step === 'check' && item.kind !== 'check') return { kind: item.kind, status: 'pending' } if (state.step === item.kind) return item.status === 'running' ? item @@ -151,15 +150,34 @@ export class SenseVoiceWorker { for (const listener of this.listeners) listener() } + /** Inspect disk caches on activation; valid resources enter standby without starting a worker. */ + inspect(): void { + this.runPreparation(async (signal) => { + using inspection = deadline(signal, this.config.prepareTimeoutMs, 'SPEECH_PREPARE_TIMEOUT') + this.publish({ phase: 'checking', step: 'check', startedAt: Date.now() }) + const runtime = await inspectRuntime(this.config, inspection.signal) + inspection.signal.throwIfAborted() + this.runtime = runtime + this.publish({ phase: runtime ? 'standby' : 'unprepared', steps: this.state.steps.map(({ kind }) => ({ kind, + status: kind === 'check' || runtime && kind !== 'load' ? 'complete' : 'pending', + })) }) + }) + } + /** Start one Host-owned preparation task; repeated callers join it. */ prepare(): void { + this.runPreparation(async (signal) => { await this.start(signal) }) + } + + private runPreparation(run: (signal: AbortSignal) => Promise): void { if (this.preparing || this.worker) return this.lifetime.signal.throwIfAborted() const abort = new AbortController() const task = { abort, settled: Promise.resolve() } this.preparing = task - task.settled = this.enqueue(async (signal) => { await this.start(signal) }, abort.signal) + task.settled = this.enqueue(run, abort.signal) .catch((error: unknown) => { + if (this.lifetime.signal.aborted) return this.publish(abort.signal.aborted ? { phase: 'cancelled' } : { phase: 'failed', message: error instanceof Error ? error.message : String(error) }) }).finally(() => { this.preparing = undefined }) @@ -215,7 +233,7 @@ export class SenseVoiceWorker { const runtime = this.runtime ?? await prepareRuntime(this.ctx, this.config, setup.signal, (state) => { this.publish(state) }) this.runtime = runtime setup.signal.throwIfAborted() - this.publish({ phase: this.loaded ? 'waking' : 'loading', step: 'load', startedAt: Date.now() }) + this.publish({ phase: cached ? 'waking' : 'loading', step: 'load', startedAt: Date.now() }) await mkdir(this.config.dataRoot, { recursive: true }) const token = randomBytes(32).toString('hex') const handle = this.ctx.subprocess.spawn({ @@ -228,7 +246,6 @@ export class SenseVoiceWorker { const port = await readReady(handle, this.config.maxLogBytes, setup.signal) const worker = { handle, url: `http://127.0.0.1:${port}`, token, closed: false } this.worker = worker - this.loaded = true this.publish({ phase: 'ready' }) const exited = (): void => { if (!worker.closed) { worker.closed = true; this.publish({ phase: 'standby' }) } @@ -272,6 +289,7 @@ export class SenseVoiceWorker { /** Stop the local recognizer. @returns after admission closes, queued jobs settle, and the managed worker exits. */ async dispose(): Promise { + this.listeners.clear() this.lifetime.abort(new Error('SenseVoice provider disposed')) clearTimeout(this.idle) try { await this.tail } finally { await this.stop() } diff --git a/packages/experimental/speech-to-text-sensevoice/src/runtime.ts b/packages/experimental/speech-to-text-sensevoice/src/runtime.ts index 2290b3658b..a2ef43a17d 100644 --- a/packages/experimental/speech-to-text-sensevoice/src/runtime.ts +++ b/packages/experimental/speech-to-text-sensevoice/src/runtime.ts @@ -1,7 +1,7 @@ /** Verified runtime assets and managed command execution for local transcription. */ import { createHash, randomUUID } from 'node:crypto' import { createReadStream, readFileSync } from 'node:fs' -import { access, mkdir, rename, rm } from 'node:fs/promises' +import { access, mkdir, rename, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' @@ -34,6 +34,62 @@ export interface RuntimePaths { readonly worker: string } +async function matchesAsset(path: string, asset: Asset, signal: AbortSignal): Promise { + signal.throwIfAborted() + try { + const info = await stat(path) + if (!info.isFile()) throw new Error(`Speech asset is not a regular file: ${path}`) + if (info.size !== asset.bytes) return false + const digest = createHash('sha256') + for await (const chunk of createReadStream(path, { signal })) digest.update(chunk as Buffer) + return digest.digest('hex') === asset.sha256 + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return false + } +} + +function resolveRuntime(config: Config) { + const supported = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64'] + if (!supported.includes(`${process.platform}-${process.arch}`)) throw new Error(`Local speech is unavailable for ${process.platform}-${process.arch}`) + const lock = JSON.parse(readFileSync(new URL('../runtime/assets.json', import.meta.url), 'utf8')) as RuntimeLock + const modelRoot = config.modelDirectory ?? join(config.dataRoot, 'models', 'sensevoice-onnx') + const paths: RuntimePaths = { + model: join(modelRoot, lock.models[config.precision].name), tokens: join(modelRoot, lock.tokens.name), + vad: config.vadModelPath ?? join(config.dataRoot, 'models', 'silero', lock.vad.name), + /* v8 ignore next -- built worker resolution is exercised by real Node and Electron process smokes */ + worker: fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url)), + } + return { lock, modelRoot, paths } +} + +async function verifyRuntime(config: Config, paths: RuntimePaths, lock: RuntimeLock, signal: AbortSignal): Promise { + signal.throwIfAborted() + try { await Promise.all([paths.model, paths.tokens, paths.vad].map(path => access(path))) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return false + } + const verified = [ + ...config.modelDirectory === undefined ? [[paths.model, lock.models[config.precision]], [paths.tokens, lock.tokens]] as const : [], + ...config.vadModelPath === undefined ? [[paths.vad, lock.vad]] as const : [], + ] + for (const [path, asset] of verified) if (!await matchesAsset(path, asset, signal)) return false + signal.throwIfAborted() + return true +} + +/** + * Inspect existing models without downloading, writing files or starting the worker. + * @param config - model paths and selected precision. + * @param signal - provider cancellation or inspection deadline. + * @returns cached paths when all files exist and managed assets match their pinned size and hash; otherwise undefined. + */ +export async function inspectRuntime(config: Config, signal: AbortSignal): Promise { + const { lock, paths } = resolveRuntime(config) + return await verifyRuntime(config, paths, lock, signal) ? paths : undefined +} + /** * Download into a unique partial file, verify, then publish it atomically. * @param asset - pinned release identity. @@ -47,13 +103,7 @@ export async function downloadAsset(asset: Asset, root: string, signal: AbortSig signal.throwIfAborted() await mkdir(root, { recursive: true }) const destination = join(root, asset.name) - try { - const digest = createHash('sha256') - for await (const chunk of createReadStream(destination, { signal })) digest.update(chunk as Buffer) - if (digest.digest('hex') === asset.sha256) return destination - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } + if (await matchesAsset(destination, asset, signal)) return destination const partial = `${destination}.${randomUUID()}.part` try { const response = await fetch(asset.url, { signal }) @@ -89,12 +139,7 @@ export async function downloadAsset(asset: Asset, root: string, signal: AbortSig */ export async function prepareRuntime(_ctx: Context, config: Config, signal: AbortSignal, report: (state: SpeechPreparationState) => void = () => {}): Promise { - const supported = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64'] - if (!supported.includes(`${process.platform}-${process.arch}`)) throw new Error(`Local speech is unavailable for ${process.platform}-${process.arch}`) - const lock = JSON.parse(readFileSync(new URL('../runtime/assets.json', import.meta.url), 'utf8')) as RuntimeLock - const modelRoot = config.modelDirectory ?? join(config.dataRoot, 'models', 'sensevoice-onnx') - const model = join(modelRoot, lock.models[config.precision].name), tokens = join(modelRoot, lock.tokens.name) - const vad = config.vadModelPath ?? join(config.dataRoot, 'models', 'silero', lock.vad.name) + const { lock, modelRoot, paths } = resolveRuntime(config) const download = async (asset: Asset, root: string, step: 'model' | 'vad'): Promise => { const url = new URL(asset.url) const pinned = { ...asset, url: `${config.modelOrigin.replace(/\/$/, '')}${url.pathname}` } @@ -110,17 +155,6 @@ export async function prepareRuntime(_ctx: Context, config: Config, signal: Abor await download(lock.vad, join(config.dataRoot, 'models', 'silero'), 'vad') } report({ phase: 'checking', step: 'verify', startedAt: Date.now() }) - await Promise.all([model, tokens, vad].map(path => access(path))) - const verified = [ - ...config.modelDirectory === undefined ? [[model, lock.models[config.precision]], [tokens, lock.tokens]] as const : [], - ...config.vadModelPath === undefined ? [[vad, lock.vad]] as const : [], - ] - for (const [path, asset] of verified) { - const digest = createHash('sha256') - for await (const chunk of createReadStream(path, { signal })) digest.update(chunk as Buffer) - if (digest.digest('hex') !== asset.sha256) throw new Error(`Speech model verification failed: ${asset.name}`) - } - signal.throwIfAborted() - /* v8 ignore next -- built worker resolution is exercised by real Node and Electron process smokes */ - return { model, tokens, vad, worker: fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url)) } + if (!await verifyRuntime(config, paths, lock, signal)) throw new Error('Speech model verification failed: missing or corrupted model files') + return paths } diff --git a/packages/experimental/speech-to-text-sensevoice/tests/installer.spec.ts b/packages/experimental/speech-to-text-sensevoice/tests/installer.spec.ts index e229a96f4a..e1030c7322 100644 --- a/packages/experimental/speech-to-text-sensevoice/tests/installer.spec.ts +++ b/packages/experimental/speech-to-text-sensevoice/tests/installer.spec.ts @@ -1,19 +1,26 @@ /** ONNX preparation reuses verified files and honors explicit offline deployments. */ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local' +import SpeechToText from '@deepseek-ai/dsh-experimental-speech-to-text' import { afterEach, expect, it, vi } from 'vitest' import type { SpeechPreparationState } from '@deepseek-ai/dsh-experimental-speech-to-text/types' -import { prepareRuntime, type Asset } from '../src/runtime.ts' +import { inspectRuntime, prepareRuntime, type Asset } from '../src/runtime.ts' import { Config } from '../src/config.ts' +import * as Provider from '../src/index.ts' const bytes = Buffer.from('pinned ONNX fixture') const asset = (name: string): Asset => ({ name, url: `https://huggingface.co/owner/model/resolve/revision/${name}`, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }) const fake = vi.hoisted(() => ({ lock: {} })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, access: vi.fn(actual.access) } +}) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, readFileSync: (...args: Parameters) => { @@ -23,9 +30,65 @@ vi.mock('node:fs', async (importOriginal) => { }) const roots: string[] = [] afterEach(async () => { + vi.restoreAllMocks() vi.unstubAllGlobals() for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) + +it('restores verified cached models on every provider activation without downloads or a worker', async () => { + const { root, fetcher } = await fixture(), config = Config({ dataRoot: root }) + await prepareRuntime(new Context(), config, new AbortController().signal) + fetcher.mockClear() + const ctx = new Context() + try { + await ctx.plugin(LocalSubprocess) + await ctx.plugin(SpeechToText) + const spawn = vi.spyOn(ctx.get('subprocess')!, 'spawn'), speech = ctx.get('speechToText')! + for (let activation = 0; activation < 2; activation++) { + const fiber = ctx.plugin(Provider, config) + await fiber + await vi.waitFor(() => { expect(speech.snapshot().providers[0]?.preparation.phase).toBe('standby') }) + expect(speech.snapshot().providers[0]?.preparation.steps?.map(({ kind, status }) => [kind, status])).toEqual([ + ['check', 'complete'], ['model', 'complete'], ['vad', 'complete'], ['verify', 'complete'], ['load', 'pending'], + ]) + expect(fetcher).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + await fiber.dispose() + expect(speech.snapshot().providers).toEqual([]) + } + } finally { await ctx.fiber.dispose() } +}) + +it.each(['model', 'tokens', 'vad'] as const)('requires preparation for a missing or corrupted cached %s', async (file) => { + const { root, fetcher } = await fixture(), config = Config({ dataRoot: root }), signal = new AbortController().signal + const paths = await prepareRuntime(new Context(), config, signal) + fetcher.mockClear() + expect(await inspectRuntime(config, signal)).toEqual(paths) + await writeFile(paths[file], Buffer.alloc(bytes.length)) + expect(await inspectRuntime(config, signal)).toBeUndefined() + await rm(paths[file]) + expect(await inspectRuntime(config, signal)).toBeUndefined() + expect(fetcher).not.toHaveBeenCalled() +}) + +it('inspects an empty cache without creating it and respects precision and offline paths', async () => { + const { root, fetcher } = await fixture(), signal = new AbortController().signal + const missingRoot = join(root, 'absent') + expect(await inspectRuntime(Config({ dataRoot: missingRoot }), signal)).toBeUndefined() + await expect(readFile(missingRoot)).rejects.toThrow() + expect(fetcher).not.toHaveBeenCalled() + const config = Config({ dataRoot: root }), paths = await prepareRuntime(new Context(), config, signal) + expect(await inspectRuntime(Config(Object.assign({}, config, { precision: 'fp32' })), signal)).toBeUndefined() + expect(await inspectRuntime(Config(Object.assign({}, config, { modelDirectory: join(root, 'models', 'sensevoice-onnx'), vadModelPath: paths.vad })), signal)).toEqual(paths) +}) + +it('reports filesystem access errors instead of treating unreadable caches as absent', async () => { + const { root, fetcher } = await fixture() + const failure = Object.assign(new Error('cache is not readable'), { code: 'EACCES' }) + vi.mocked(access).mockRejectedValueOnce(failure) + await expect(inspectRuntime(Config({ dataRoot: root }), new AbortController().signal)).rejects.toBe(failure) + expect(fetcher).not.toHaveBeenCalled() +}) async function fixture() { const root = await mkdtemp(join(tmpdir(), 'dsh-speech-onnx-')); roots.push(root) fake.lock = { models: { int8: asset('model.int8.onnx'), fp32: asset('model.onnx') }, tokens: asset('tokens.txt'), vad: asset('silero_vad.onnx') } diff --git a/packages/experimental/speech-to-text-sensevoice/tests/worker.spec.ts b/packages/experimental/speech-to-text-sensevoice/tests/worker.spec.ts index c00df0a59d..09af51a3c7 100644 --- a/packages/experimental/speech-to-text-sensevoice/tests/worker.spec.ts +++ b/packages/experimental/speech-to-text-sensevoice/tests/worker.spec.ts @@ -9,9 +9,9 @@ import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local' import { afterEach, expect, it, vi } from 'vitest' import { Config } from '../src/config.ts' import { SenseVoiceWorker } from '../src/recognizer.ts' -import { prepareRuntime } from '../src/runtime.ts' +import { inspectRuntime, prepareRuntime } from '../src/runtime.ts' -vi.mock('../src/runtime.ts', () => ({ prepareRuntime: vi.fn() })) +vi.mock('../src/runtime.ts', () => ({ inspectRuntime: vi.fn(), prepareRuntime: vi.fn() })) const cleanup: Array<() => Promise> = [] afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); vi.clearAllMocks() }) @@ -51,6 +51,63 @@ it('keeps one worker warm across recordings and joins its exit on disposal', asy await expect(worker.transcribe({ audio, language: 'en' }, signal())).rejects.toThrow('disposed') }) +it('wakes verified caches on the first recording without preparing them again', async () => { + const { worker, spawn, root } = await fixture() + const paths = { tokens: root, model: root, vad: root, worker: fileURLToPath(new URL('./worker.fixture.mjs', import.meta.url)) } + vi.mocked(inspectRuntime).mockResolvedValueOnce(paths) + const phases: string[] = [] + worker.subscribe(() => { phases.push(worker.snapshot().phase) }) + worker.inspect(); worker.inspect() + await vi.waitFor(() => { expect(worker.snapshot().phase).toBe('standby') }) + expect(inspectRuntime).toHaveBeenCalledOnce() + expect(spawn).not.toHaveBeenCalled() + expect((await worker.transcribe({ audio, language: 'zh' }, signal())).text).toBe('zh') + expect(phases).toContain('waking') + expect(phases).not.toContain('loading') + expect(prepareRuntime).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledOnce() +}) + +it('keeps absent caches unprepared and reports unreadable caches as retryable failures', async () => { + const { worker, spawn } = await fixture() + vi.mocked(inspectRuntime).mockResolvedValueOnce(undefined) + worker.inspect() + await vi.waitFor(() => { expect(worker.snapshot().steps?.[0]?.status).toBe('complete') }) + expect(worker.snapshot().phase).toBe('unprepared') + expect(spawn).not.toHaveBeenCalled() + expect(prepareRuntime).not.toHaveBeenCalled() + vi.mocked(inspectRuntime).mockRejectedValueOnce(new Error('cache unreadable')) + worker.inspect() + await vi.waitFor(() => { expect(worker.snapshot()).toMatchObject({ phase: 'failed', message: 'cache unreadable' }) }) + worker.prepare() + await vi.waitFor(() => { expect(worker.snapshot().phase).toBe('ready') }, { timeout: 10000 }) + expect(prepareRuntime).toHaveBeenCalledOnce() +}) + +it('cancels cache inspection and joins it without late notifications when the provider unloads', async () => { + const { worker } = await fixture(), entered = Promise.withResolvers(), released = Promise.withResolvers() + vi.mocked(inspectRuntime).mockImplementationOnce(async (_config, signal) => { + entered.resolve(undefined) + await released.promise + signal.throwIfAborted() + return undefined + }) + const changed = vi.fn() + worker.subscribe(changed) + worker.inspect(); await entered.promise + const cancelled = worker.cancel() + expect(worker.snapshot().phase).toBe('cancelling') + let settled = false + const disposed = worker.dispose().then(() => { settled = true }) + const notifications = changed.mock.calls.length + await Promise.resolve() + expect(settled).toBe(false) + released.resolve(undefined) + await cancelled; await disposed + expect(changed).toHaveBeenCalledTimes(notifications) + expect(prepareRuntime).not.toHaveBeenCalled() +}) + it('cancels active inference before the next recording can acquire a fresh worker', async () => { const { worker, root, spawn } = await fixture() const cancel = new AbortController() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index f08e818bf6..e5f8c3224b 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1582,6 +1582,44 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', }, + { + key: 'plugins.bundle.activation', + kind: 'keyed', + scope: 'root', + summary: 'Optional guidance after the user enables a bundle from the list, keyed by npm package name.', + doc: 'Optional guidance after the user enables a bundle from the list, keyed by npm package name.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** One user-requested bundle activation and navigation to its configuration page. */\nexport interface PluginActivationOwnerProps {\n readonly packageName: string\n /** Dismiss guidance for this activation. */\n readonly onDismiss: () => void\n /** Dismiss guidance and open this bundle\'s detail page. */\n readonly onOpenDetails: () => void\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [ + 'experimental-client-ui-voice-input VoiceSetupPrompt key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:41', + }, { key: 'plugins.bundle.config', kind: 'keyed', @@ -1618,7 +1656,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:46', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:57', }, { key: 'plugins.bundle.status', @@ -1656,7 +1694,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.status\', () => ctx.slots.register(\n { name: \'plugins.bundle.status\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:30', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:39', }, { key: 'plugins.item', @@ -1709,7 +1747,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:40', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:51', }, { key: 'plugins.row.config', @@ -1745,7 +1783,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:53', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:64', }, { key: 'rightbar', From 9d0f475a47d3c0b22a54d357924c953651670267 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 13:33:38 +0800 Subject: [PATCH 03/36] fix(voice-input): keep preparation status in plugin details --- ...6-09-16-experimental-voice-input.i18n.yaml | 4 +- .../2026-09-16-experimental-voice-input.md | 2 +- .../2026-09-16-experimental-voice-input.zh.md | 2 +- apps/web/tests/voice-setup.e2e.ts | 2 + apps/web/tsconfig.json | 1 + docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 +- docs/subsystems/slots.zh.md | 2 +- docs/subsystems/voice-input.i18n.yaml | 4 +- docs/subsystems/voice-input.md | 2 +- docs/subsystems/voice-input.zh.md | 2 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 8 ++-- .../ui-plugin-manager/src/client/index.ts | 3 +- .../src/client/slot-contract.ts | 8 ---- .../tests/components.client.spec.tsx | 10 ---- .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 2 +- .../client-ui-voice-input/README.zh.md | 2 +- .../src/client/PreparationCard.tsx | 11 ----- .../src/client/VoiceInput.module.css | 1 - .../src/client/locales.ts | 20 -------- .../client-ui-voice-input/src/client/mount.ts | 5 +- .../tests/mount.client.spec.ts | 3 +- .../tests/preparation.client.spec.tsx | 8 +--- .../src/client/slot-catalog.ts | 46 ++----------------- tsconfig.host.json | 1 + 29 files changed, 36 insertions(+), 131 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml index 83b32e2b63..8e6b585de4 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml @@ -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-16-experimental-voice-input.md -2026-09-16-experimental-voice-input.md: b63d70a190d962b523fde9b76423d0da9ecb45fb -2026-09-16-experimental-voice-input.zh.md: 9a21aaf1a622bcad3f47b8b0f95bb5b18023941f +2026-09-16-experimental-voice-input.md: 8b646e917965175f099c37224261ca05c7c1adfb +2026-09-16-experimental-voice-input.zh.md: 15ffc46d8e964c0448e8bd1c7395a5ad464b2b2c diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md index b63d70a190..8b646e9179 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md @@ -18,7 +18,7 @@ Recordings and transcripts remain transient until the user submits ordinary text 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. 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. Package status uses `plugins.bundle.status` and details use the upstream `plugins.bundle.config`, both 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 without replacing the draft editor or context-usage control. 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. +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 without replacing the draft editor or context-usage control. 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 diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md index 9a21aaf1a6..15ffc46d8e 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md @@ -18,7 +18,7 @@ Provider id 选择确切的注册。解析捕获实例,注销先拒绝新任 SenseVoice ONNX 与 Silero VAD 在一个托管 CPU 子进程中运行。原生 sherpa-onnx 包包含 ONNX Runtime;显式准备只下载并校验模型;后续调用复用预热模型,直到配置的空闲截止时间。每次启用都会校验缓存资源,完整缓存恢复为待唤醒状态,不启动工作进程。就绪状态因此取决于磁盘内容,而非 Provider 实例的存活时间。检查复用准备任务的生命周期,取消与释放都会等待检查结束。禁用 Provider 等待其任务和进程退出,同时保留可复用的磁盘资源。浏览器原生录音与重采样避免增加音频转换可执行文件。带认证的私有环回协议传输完整 PCM 录音,取消时可以终止工作进程,而不与另一请求共享长时间推理。 -Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。包状态使用 `plugins.bundle.status`,详情使用上游 `plugins.bundle.config`,均以包名为 key。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和独立的上下文用量控件。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。 +Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和独立的上下文用量控件。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。 ## 考虑的替代方案 diff --git a/apps/web/tests/voice-setup.e2e.ts b/apps/web/tests/voice-setup.e2e.ts index d3767fa7d3..93eb86b5c5 100644 --- a/apps/web/tests/voice-setup.e2e.ts +++ b/apps/web/tests/voice-setup.e2e.ts @@ -31,6 +31,8 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo 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() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 880d27e739..1f7b31cc86 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,6 +26,7 @@ "tests/diff-context.e2e.ts", "tests/scaffold.ts", "tests/voice-input.e2e.ts", + "tests/voice-setup.e2e.ts", "tests/auto-review-fixture.ts", "tests/scaffold-generation.spec.ts", "tests/scaffold-hermetic.e2e.ts", diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 8c608cc994..00b9312824 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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/slots.md -slots.md: eb3fed3df595b7cd0b777109ed67979d14d579c5 -slots.zh.md: a02d292b2c6cd268a768e0b3e9254f2e0f36a1e4 +slots.md: e4cc9ac9977d680fdd7235b01089eb6b0ba31caf +slots.zh.md: 00f1772010d878083ca354ced1ef808454bea094 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index eb3fed3df5..e4cc9ac997 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -6,7 +6,7 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md). -`plugins.bundle.status` is keyed by npm package name and renders short status before the bundle switch; `plugins.bundle.config` supplies bundle detail configuration. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. +`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. ## Declaration and lifecycle diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index a02d292b2c..00f1772010 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -6,7 +6,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。 -`plugins.bundle.status` 以 npm 包名为 key,在 Bundle 卡片开关前显示简短状态;`plugins.bundle.config` 提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 +`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 ## 声明与生命周期 diff --git a/docs/subsystems/voice-input.i18n.yaml b/docs/subsystems/voice-input.i18n.yaml index bde639522c..7e94efdd15 100644 --- a/docs/subsystems/voice-input.i18n.yaml +++ b/docs/subsystems/voice-input.i18n.yaml @@ -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/voice-input.md -voice-input.md: d72bf01a372413fbc33a93a8a3a77f5152a3807b -voice-input.zh.md: c45275516166820f9dfac2d7d96c3a43b70d72d8 +voice-input.md: c1507d27705131243890d7441c8a945ca63ba419 +voice-input.zh.md: 9300f4cf110a748ec54bf809526fac2f2ea35eaa diff --git a/docs/subsystems/voice-input.md b/docs/subsystems/voice-input.md index d72bf01a37..c1507d2770 100644 --- a/docs/subsystems/voice-input.md +++ b/docs/subsystems/voice-input.md @@ -20,7 +20,7 @@ The input facade captures a revision-bearing selection before recording. `InputA The Host provider owns one preparation task across page and Session changes. The Client shares one `follow()` subscription across the composer, plugin status, 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 store recognition preferences, and `plugins.bundle.status` displays short readiness beside the enable switch. +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 diff --git a/docs/subsystems/voice-input.zh.md b/docs/subsystems/voice-input.zh.md index c452755161..9300f4cf11 100644 --- a/docs/subsystems/voice-input.zh.md +++ b/docs/subsystems/voice-input.zh.md @@ -20,7 +20,7 @@ Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、插件状态、详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。 -麦克风占用模型选择器与发送按钮之间的 `conversation.input.activity`。点击开始录音并展开工具栏;停止后转写并插入草稿。活动栏保留编辑器与提交按钮,拥有局部反馈,并在卸载时释放展开状态。取消、Escape 或隐藏页面会丢弃录音。波形历史展示实测麦克风音量。Bundle 详情保存识别偏好,`plugins.bundle.status` 在启用开关旁显示简短准备状态。 +麦克风占用模型选择器与发送按钮之间的 `conversation.input.activity`。点击开始录音并展开工具栏;停止后转写并插入草稿。活动栏保留编辑器与提交按钮,拥有局部反馈,并在卸载时释放展开状态。取消、Escape 或隐藏页面会丢弃录音。波形历史展示实测麦克风音量。Bundle 详情包含识别偏好、准备状态和进度。显式启用时通过 `plugins.bundle.activation` 引导缺少模型的用户前往安装;列表只显示 Bundle 描述和开关。 ## 设计依据 diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 379cfb3c3b..b5171caf3e 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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-plugin-manager/README.md -README.md: 1be8aaf8aed93dcfef527f373300f5cfb49c9bb6 -README.zh.md: eac6512b91916302e0d265045365d0ad8e890c82 +README.md: 34eca80392ad66f0e1a170cc5455f81d4650f5d3 +README.zh.md: 14d1af9b240c23b780c1afd58fbdc2abda43f5b9 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 1be8aaf8ae..34eca80392 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -79,7 +79,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. -`plugins.bundle.status` is keyed by npm package name and renders short status before the bundle switch; `plugins.bundle.config` supplies bundle detail configuration. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. +`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index eac6512b91..14d1af9b24 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -79,7 +79,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ 页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 -`plugins.bundle.status` 以 npm 包名为 key,在 Bundle 卡片开关前显示简短状态;`plugins.bundle.config` 提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 +`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 3a94095741..d507993bcf 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -33,7 +33,7 @@ import css from './PluginManagerPage.module.css' export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> - & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.status' | 'plugins.bundle.activation'> + & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.activation'> & InjectFace /** The page's slot renderer, narrowed to the configuration slots. */ @@ -282,8 +282,7 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { } /** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ -function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled, statusContent }: { - readonly statusContent: ReactNode +function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { readonly pkg: PackageView readonly t: Translate readonly busy: boolean @@ -311,7 +310,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled, statusCo )} description={description} - end={<>{statusContent}} + end={} /> ) @@ -874,7 +873,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { t={t} busy={state.busy.includes(pkg.name)} highlighted={state.highlight === pkg.name} - statusContent={renderSlot('plugins.bundle.status', { packageName: pkg.name, enabled: pkg.enabled }, { entryKey: pkg.name })} onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name }) }} onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }} /> diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 102a73a3d7..f11d187fa0 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -31,7 +31,7 @@ export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' -export type { PluginConfigViewProps, PluginPackageOwnerProps, PluginActivationOwnerProps } from './slot-contract.ts' +export type { PluginConfigViewProps, PluginActivationOwnerProps } from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -87,7 +87,6 @@ export function apply(ctx: ClientContext): void { inject: () => controller.inject(configLedger), children: { 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.status': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 9fd4fec14b..5548602791 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -18,12 +18,6 @@ export interface PluginConfigViewProps { readonly view: 'summary' | 'page' } -/** Package identity and activation state supplied to its compact status view. */ -export interface PluginPackageOwnerProps { - readonly packageName: string - readonly enabled: boolean -} - /** One user-requested bundle activation and navigation to its configuration page. */ export interface PluginActivationOwnerProps { readonly packageName: string @@ -35,8 +29,6 @@ export interface PluginActivationOwnerProps { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - /** Short readiness beside the bundle switch, keyed by npm package name. */ - 'plugins.bundle.status': { kind: 'keyed'; scope: 'root'; owner: PluginPackageOwnerProps } /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ 'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps } /** diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 12f1019827..246f942839 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -703,16 +703,6 @@ describe('PluginManagerPage', () => { }) }) -it('places bundle readiness immediately before its enable switch', () => { - const name = '@deepseek-ai/dsh-experimental-voice-input-bundle' - renderTab({ packages: [pkg({ name })] }, {}, { - [`plugins.bundle.status:${name}`]: () => Ready for dictation, - }) - const status = screen.getByText('Ready for dictation'), toggle = screen.getByRole('switch') - expect(status.parentElement).toBe(toggle.parentElement) - expect(status.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() -}) - it('offers bundle-owned guidance only after explicit enablement and navigates to its detail page', () => { const name = 'dsh-better-sidebar' const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, { diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index ff5188a628..2666ead465 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -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/experimental/client-ui-voice-input/README.md -README.md: f6a7c2ba0e8567205ddd093b85915a487c3da898 -README.zh.md: 42657a6f0d73d61caefb12c83e7d73d7408d52cb +README.md: 539de47b0e1abe400b359f2ea4c2d55ede7554c0 +README.zh.md: 1c15160f333838de61ab25c64817e1a63e7cf5ce diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index f6a7c2ba0e..539de47b0e 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -35,7 +35,7 @@ Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. A
Maintainer details — click to expand -Native MediaRecorder captures audio and Web Audio converts it to the Host PCM format after flushing the final recorder chunks. The original editor selection carries a draft revision: changed drafts retain the transcript for explicit insertion or discard beside the microphone. Session changes, hiding the page during capture and plugin disposal invalidate late results and release tracks. One plugin-owned readiness subscription serves the composer, package cards, details. These views use the existing Slot lifecycle and own no preparation tasks. No runtime invariant companion is published because readiness comes from one Host subscription and recording state belongs to one capture operation. +Native MediaRecorder captures audio and Web Audio converts it to the Host PCM format after flushing the final recorder chunks. The original editor selection carries a draft revision: changed drafts retain the transcript for explicit insertion or discard beside the microphone. Session changes, hiding the page during capture and plugin disposal invalidate late results and release tracks. One plugin-owned readiness subscription serves the composer, installation prompt and details. These views use the existing Slot lifecycle and own no preparation tasks. No runtime invariant companion is published because readiness comes from one Host subscription and recording state belongs to one capture operation.
diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index 42657a6f0d..1c15160f33 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference"
维护者信息 — 点击展开 -原生 MediaRecorder 捕获音频,等待最后一块数据后由 Web Audio 转换为 Host 的 PCM 格式。原编辑器选区携带草稿版本:草稿变化时,转写文字保留在麦克风旁,供显式插入或丢弃。切换 Session、录音期间隐藏页面和释放插件会使迟到结果失效并释放轨道。插件拥有的一份准备状态订阅服务于输入框、包卡片、详情。这些视图使用既有 Slot 生命周期,不拥有准备任务。不发布运行时不变量伴随模块,因为准备状态来自一份 Host 订阅,录音状态属于一个捕获操作。 +原生 MediaRecorder 捕获音频,等待最后一块数据后由 Web Audio 转换为 Host 的 PCM 格式。原编辑器选区携带草稿版本:草稿变化时,转写文字保留在麦克风旁,供显式插入或丢弃。切换 Session、录音期间隐藏页面和释放插件会使迟到结果失效并释放轨道。插件拥有的一份准备状态订阅服务于输入框、安装引导和详情。这些视图使用既有 Slot 生命周期,不拥有准备任务。不发布运行时不变量伴随模块,因为准备状态来自一份 Host 订阅,录音状态属于一个捕获操作。
diff --git a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx index d8639c6e9c..28d74ad143 100644 --- a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx @@ -99,17 +99,6 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio } -/** Short selected-provider readiness for the plugin card's switch-adjacent status Slot. */ -export function VoiceStatus({ useSpeechReadiness, t }: InjectFace & PropsLocale) { - const readiness = useSpeechReadiness(value => value) - const provider = readiness.catalog?.providers.find(item => item.id === readiness.catalog?.selection.providerId) - const state = provider?.preparation - const text = !readiness.connected || !state ? t('short.connecting') : t(`short.${state.phase}`) - return - {text} - -} - /** Recognition preferences and preparation cards shared by plugin details and Settings. */ export function VoicePreparation({ useSpeechReadiness, ...props }: InjectFace & PropsLocale) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css index 4b4c9e3403..4881303583 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css @@ -21,7 +21,6 @@ .stepBody { flex: 1; min-width: 0; } .stepProgress { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } .stepProgress small { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } -.packageStatus { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary); white-space: nowrap; } .preferences { display: grid; gap: 12px; font-size: 13px; margin: 18px 0; } .preferences label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; } .preferences select { border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; } diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index 918652da79..9b9c33cb99 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -28,18 +28,8 @@ export const zh = { 'stepStatus.complete': '已完成', 'stepStatus.failed': '失败', 'stepStatus.cancelled': '已取消', - 'short.connecting': '连接中', - 'short.unprepared': '需要安装', - 'short.checking': '检查中', 'short.downloading': '下载中', - 'short.installing': '安装中', - 'short.loading': '加载中', - 'short.waking': '唤醒中', - 'short.cancelling': '取消中', - 'short.cancelled': '需要安装', 'short.failed': '准备失败', - 'short.ready': '已就绪', - 'short.standby': '已就绪', retryRecording: '重新录音', preparationSteps: '准备步骤', 'prepare': '下载并准备', @@ -109,18 +99,8 @@ export const en: Record = { 'stepStatus.complete': 'Completed', 'stepStatus.failed': 'Failed', 'stepStatus.cancelled': 'Cancelled', - 'short.connecting': 'Connecting', - 'short.unprepared': 'Setup required', - 'short.checking': 'Checking', 'short.downloading': 'Downloading', - 'short.installing': 'Installing', - 'short.loading': 'Loading', - 'short.waking': 'Waking', - 'short.cancelling': 'Cancelling', - 'short.cancelled': 'Setup required', 'short.failed': 'Setup failed', - 'short.ready': 'Ready', - 'short.standby': 'Ready', retryRecording: 'Record again', preparationSteps: 'Preparation steps', 'prepare': 'Download and prepare', diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index 3af8f6d451..cff12b511b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -11,7 +11,7 @@ import { Recording } from './audio.ts' import { en, NS, zh } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import { observeReadiness } from './readiness.ts' -import { VoicePreparation, VoiceStatus } from './PreparationCard.tsx' +import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' export const inject = ['remote', 'slots', 'locale'] @@ -39,9 +39,6 @@ function registerUi(ctx: Context): void { ctx.slots.inject('conversation.input.activity', () => ctx.slots.register({ name: 'conversation.input.activity', locale: NS, inject: () => actions, }, VoiceInput)) - ctx.slots.inject('plugins.bundle.status', () => ctx.slots.register({ name: 'plugins.bundle.status', - key: '@deepseek-ai/dsh-experimental-voice-input-bundle', locale: NS, inject: () => actions, - }, VoiceStatus)) ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: '@deepseek-ai/dsh-experimental-voice-input-bundle', locale: NS, inject: () => actions, }, VoicePreparation)) diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index a11f6d7eac..2f5833d882 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -30,7 +30,6 @@ async function fixture(fail = false) { await ctx.plugin(SlotRegistry) ctx.slots.register({ name: 'root', children: { 'conversation.input.activity': { kind: 'single', scope: 'session' }, - 'plugins.bundle.status': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, } } as never, @@ -55,7 +54,7 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a await actions.configure({ language: 'zh' }) await actions.prepare('local' as SpeechProviderId) await actions.cancelPreparation('local' as SpeechProviderId) - for (const slot of ['plugins.bundle.status', 'plugins.bundle.config', 'plugins.bundle.activation'] as const) { + for (const slot of ['plugins.bundle.config', 'plugins.bundle.activation'] as const) { const item = b.ctx.slots.entries(slot)[0]! expect(item.locale).toBe('voice-input') const injected = (item.inject as unknown as () => VoiceInputInjected & { compact?: boolean })() diff --git a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx index 8315a9a4c7..ba8bb03680 100644 --- a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx @@ -6,7 +6,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SpeechPreparationState, SpeechPreparationStep, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import { afterEach, expect, it, vi } from 'vitest' -import { PreparationCard, VoiceStatus, VoicePreparation } from '../src/client/PreparationCard.tsx' +import { PreparationCard, VoicePreparation } from '../src/client/PreparationCard.tsx' import type { VoiceInputProps } from '../src/client/VoiceInput.tsx' import type { SpeechReadiness } from '../src/client/readiness.ts' import { zh } from '../src/client/locales.ts' @@ -84,7 +84,7 @@ it('shows waking without an explicit preparation cancellation control', () => { expect(screen.getByText('已等待 5 秒')).toBeTruthy() expect(screen.queryByRole('button', { name: zh.cancelPrepare })).toBeNull() }) -it('persists settings and renders a short selected-provider status independently of the detail card', async () => { +it('persists settings and reports disconnection in the detail card', async () => { const store = createSnapshotStore({ connected: true, error: null, catalog: { selection: { providerId: id, language: 'auto' }, maxAudioBytes: 100, maxDurationSeconds: 120, providers: [{ id, name: 'SenseVoiceSmall', location: 'host-local', preparation: { phase: 'ready' } }, @@ -105,11 +105,7 @@ it('persists settings and renders a short selected-provider status independently configure.mockRejectedValueOnce('settings are locked') fireEvent.change(screen.getByLabelText(zh.language), { target: { value: 'ja' } }) await screen.findByText('语音识别失败:settings are locked') - view.rerender() - expect(screen.getByRole('status').textContent).toBe(zh['short.ready']) - expect(screen.queryByRole('combobox')).toBeNull() act(() => { store.set({ catalog: null, connected: false, error: 'disconnected' }) }) - expect(screen.getByRole('status').textContent).toBe(zh['short.connecting']) view.rerender() expect(screen.getByText(zh.loading)).toBeTruthy() expect(screen.getByRole('alert').textContent).toContain('disconnected') diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e5f8c3224b..8e2d4903f5 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1618,7 +1618,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:41', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:33', }, { key: 'plugins.bundle.config', @@ -1656,45 +1656,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:57', - }, - { - key: 'plugins.bundle.status', - kind: 'keyed', - scope: 'root', - summary: 'Short readiness beside the bundle switch, keyed by npm package name.', - doc: 'Short readiness beside the bundle switch, keyed by npm package name.', - registerOptions: [ - { - name: 'key', - requirement: 'required', - type: 'string', - doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', - }, - ], - ownerProps: [ - '/** Package identity and activation state supplied to its compact status view. */\nexport interface PluginPackageOwnerProps {\n readonly packageName: string\n readonly enabled: boolean\n}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionStatus: UseSessionStatus', - 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', - ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', - occupants: [ - 'experimental-client-ui-voice-input VoiceStatus key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.status\', () => ctx.slots.register(\n { name: \'plugins.bundle.status\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:39', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:49', }, { key: 'plugins.item', @@ -1747,7 +1709,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:51', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:43', }, { key: 'plugins.row.config', @@ -1783,7 +1745,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:64', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:56', }, { key: 'rightbar', diff --git a/tsconfig.host.json b/tsconfig.host.json index a434e5a361..f5e24d790b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,6 +13,7 @@ "apps/web/tests/diff-context.e2e.ts", "apps/web/tests/scaffold.ts", "apps/web/tests/voice-input.e2e.ts", + "apps/web/tests/voice-setup.e2e.ts", "apps/web/tests/auto-review-fixture.ts", "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", From 018c0e515658d831877895ffd7c3338a12750252 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 13:57:51 +0800 Subject: [PATCH 04/36] fix(voice-input): align dictation toolbar controls --- ...026-09-16-experimental-voice-input.i18n.yaml | 4 ++-- .../2026-09-16-experimental-voice-input.md | 2 +- .../2026-09-16-experimental-voice-input.zh.md | 2 +- apps/web/tests/voice-input.e2e.ts | 17 +++++++++++++---- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/InputBar.module.css | 2 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 14 +++++++++++--- .../src/client/VoiceInput.module.css | 3 ++- .../src/client/VoiceInput.tsx | 6 +++--- 12 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml index 8e6b585de4..84bd998a51 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml @@ -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-16-experimental-voice-input.md -2026-09-16-experimental-voice-input.md: 8b646e917965175f099c37224261ca05c7c1adfb -2026-09-16-experimental-voice-input.zh.md: 15ffc46d8e964c0448e8bd1c7395a5ad464b2b2c +2026-09-16-experimental-voice-input.md: 60a871cc6ba7e604896ce4e339bcfbe45d174f9b +2026-09-16-experimental-voice-input.zh.md: 76d14511d1b014bb6d40cc4bf3e9e6ed19efc3f6 diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md index 8b646e9179..60a871cc6b 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md @@ -18,7 +18,7 @@ Recordings and transcripts remain transient until the user submits ordinary text 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. 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 without replacing the draft editor or context-usage control. 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. +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 precedes the idle microphone; 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 diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md index 15ffc46d8e..76d14511d1 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md @@ -18,7 +18,7 @@ Provider id 选择确切的注册。解析捕获实例,注销先拒绝新任 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 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。 +Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和提交按钮。空闲时上下文用量按钮位于麦克风之前;录音和转写期间,用量按钮与普通工具栏控件一起隐藏,关闭活动后恢复。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。 ## 考虑的替代方案 diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index b1b1f665b8..34b0332908 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -88,20 +88,29 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi await page.setViewportSize({ width, height: 900 }) await expect.poll(async () => { const micRect = await mic.boundingBox(), meterRect = await meter.boundingBox() - return micRect !== null && meterRect !== null && micRect.x + micRect.width <= meterRect.x + return micRect !== null && meterRect !== null && meterRect.x + meterRect.width <= micRect.x }).toBe(true) await meter.click() await page.getByRole('dialog', { name: 'of context used', exact: true }).waitFor() await page.keyboard.press('Escape') } expect(await mic.getAttribute('title')).toBe('Dictate') + const controlBackground = await page.getByRole('button', { name: 'Add files or run commands', exact: true }) + .evaluate(element => getComputedStyle(element).backgroundColor) await mic.click() - await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).waitFor() - expect(await meter.isVisible()).toBe(true) + const stop = page.getByRole('button', { name: 'Stop and transcribe', exact: true }) + await stop.waitFor() + 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.waitFor() expect(messages).toBe(1) expect(tripwire.pageErrors).toEqual([]) }, 120_000) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 73e1bacf58..e192087d0f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 87a939fb42b4dc451fb66dbfe1a58262614e5b70 -README.zh.md: 12d5c955f3b8f86d7ea3ccaf3681ef95595486e9 +README.md: 548ffd3c49e6ac9bf9769c7cf252439387e9de5b +README.zh.md: becdb0da3b832458bfc9154cccf0efa265e46a65 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 87a939fb42..548ffd3c49 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -116,7 +116,7 @@ The selector must be a pure function of the owner currency. Its non-null return `InputActions.captureInsertion()` captures the draft selection and revision; `insertText(text, span)` inserts one undoable plain-text edit only while that revision is current and the editor permits editing. Asynchronous consumers retain rejected results for user action. -`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls while preserving the editor, context-usage control and submit action. The context meter keeps its own space between the activity and Send. The occupant releases expansion on unmount and owns any activity-specific feedback. +`conversation.input.activity` hosts one control between the context meter and Send, following the model selector. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls. The occupant releases expansion on unmount and owns any activity-specific feedback. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 12d5c955f3..becdb0da3b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -116,7 +116,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match `InputActions.captureInsertion()` 捕获草稿选区与版本;`insertText(text, span)` 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。 -`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件,同时保留编辑器、上下文用量控件和提交按钮。上下文用量按钮在活动控件与发送按钮之间保留独立位置。占用者在卸载时释放展开状态,并拥有活动专属反馈。 +`conversation.input.activity` 在上下文用量与发送按钮之间承载一个控件,位于模型选择器之后。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件。占用者在卸载时释放展开状态,并拥有活动专属反馈。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 2513e2f231..ff1bcea3e7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -394,7 +394,7 @@ } .standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; } -.tools[hidden], .standardControls[hidden] { display: none; } +.tools[hidden], .standardControls[hidden], .contextControl[hidden] { display: none; } .trailingActive { flex: 1; margin-left: 0; } .activity, .contextControl { display: flex; align-items: center; flex: none; } .activity:empty, .contextControl:empty { display: none; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 4f64fbacb1..0220623290 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -446,10 +446,10 @@ export const InputBar = memo(function InputBar({ : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
+ {input === undefined || sessionId === undefined ? null :
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} -
{interruptible && ( , + activityEntry: owner => <> + + + , }) const meter = view.getByRole('button', { name: '上下文已用 25%' }) - fireEvent.click(view.getByRole('button', { name: 'microphone' })) + const microphone = view.getByRole('button', { name: 'microphone' }) + expect(meter.compareDocumentPosition(microphone) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(microphone) + expect(view.queryByRole('button', { name: '上下文已用 25%' })).toBeNull() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'close activity' })) expect(view.getByRole('button', { name: '上下文已用 25%' })).toBe(meter) fireEvent.click(meter) expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css index 4881303583..28b75820e0 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css @@ -1,6 +1,7 @@ .trigger { width: 28px; padding: 0; flex: none; } .captureRow { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; min-height: 34px; } -.roundButton { width: 32px; height: 32px; padding: 0; border-radius: 50%; corner-shape: round; flex: none; } +.roundButton { width: 32px; height: 32px; padding: 0; border-radius: 50%; corner-shape: round; flex: none; background: var(--dsw-specific-selector); } +.roundButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid); } .waveform { display: block; flex: 1; width: 0; height: 34px; min-width: 24px; color: var(--dsw-alias-label-secondary); } .activityMessage { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .inlineAction { flex: none; } diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 4d6057be8b..6d50aefcce 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -132,20 +132,20 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, aria-label={t('start')} title={t(usable ? 'dictate' : 'prepareRequired')} onMouseDown={(event) =>{ event.preventDefault() }} onClick={() => { void start() }}> return
- {phase === 'recording' ? : {(phase === 'requesting' || phase === 'transcribing') && } {phase === 'feedback' ? message : t(phase === 'requesting' ? 'requesting' : provider?.preparation.phase === 'waking' ? 'wakingShort' : 'transcribingShort')}} - {phase === 'recording' && } {phase === 'feedback' && (pending ? - : )}
} From 7e12403d8439bd25dcd7b68003f74d74e3eccc4a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 14:11:11 +0800 Subject: [PATCH 05/36] fix(voice-input): reduce recording action icon sizes --- .../client-ui-voice-input/src/client/VoiceInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 6d50aefcce..977810e3ad 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -133,14 +133,14 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, onMouseDown={(event) =>{ event.preventDefault() }} onClick={() => { void start() }}> return
+ onClick={cancel}> {phase === 'recording' ? : {(phase === 'requesting' || phase === 'transcribing') && } {phase === 'feedback' ? message : t(phase === 'requesting' ? 'requesting' : provider?.preparation.phase === 'waking' ? 'wakingShort' : 'transcribingShort')}} {phase === 'recording' && } + onClick={() => { void finish() }}>} {phase === 'feedback' && (pending ? + } + anchor={} /> ))} @@ -75,3 +86,19 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { }
} + +/** Mounted only while its hover card is open, so passing over a row does not read a comparison. */ +function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, loadChangesDiff, t }: PreviewProps & { + seq: number + index: number + display: string +} & PropsLocale) { + const state = useChangesDiff(value => value[changesDiffUrl(sessionId, seq, index)]) + useEffect(() => { + if (state === undefined) void loadChangesDiff(sessionId, seq, index) + }, [state, sessionId, seq, index, loadChangesDiff]) + return
+
{display}
+ { void loadChangesDiff(sessionId, seq, index) }} /> +
+} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 3fae39bd01..5bcdb34946 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -5,6 +5,7 @@ import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } fr import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' +import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import { ChangedFiles } from './ChangedFiles.tsx' import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts' @@ -21,12 +22,14 @@ const COLLAPSED_PRESENTED_COUNT = 4 /** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { hooks: { + changesDiff: ObservableSnapshot> showCodeDiff: ObservableSnapshot presentedOpen: ObservableSnapshot> presentedHost: ObservableSnapshot> changesSummary: ObservableSnapshot> } reloadPresentedHost: PresentedOpenController['loadHost'] + loadChangesDiff: ChangesDiffStore['load'] loadChangesSummary: ChangesSummaryStore['load'] openPresented: PresentedOpenController['open'] openChanged: PresentedOpenController['openChanged'] @@ -64,7 +67,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail */ export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, - useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, + useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { @@ -89,7 +92,8 @@ export function Deliverables({ if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) return <> - {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
| undefined + new: ReadonlyMap | undefined +} + +function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { + const source = rows.flatMap((row) => { + const no = row[side] + return no === undefined ? [] : [{ no, text: row.text }] + }) + if (source.length === 0) return new Map() + const highlighted = highlighter(source.map(line => line.text).join('\n')) + if (highlighted === undefined) return undefined + return new Map(source.map((line, index) => { + // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. + return [line.no, highlighted[index] ?? []] + })) +} + +function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { + const rows = hunkRows(hunk) + return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } +} + +function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { + return + {spans === undefined ? text : spans.map((span, index) => {span.text})} + +} + +/** + * Number a hunk's lines: context lines count on both sides, deletions on the + * old side, additions on the new side. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + return hunk.lines.map((line) => { + const text = line.slice(1) + switch (line[0]) { + case '+': return { kind: 'add', old: undefined, new: newNo++, text } + case '-': return { kind: 'del', old: oldNo++, new: undefined, text } + default: return { kind: 'context', old: oldNo++, new: newNo++, text } + } + }) +} + +/** + * Pair a hunk's lines for the side-by-side view: each run of deletions is + * aligned with the run of additions that follows it, row by row, and context + * lines sit on both sides. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { + const rows: SplitRow[] = [] + let dels: NonNullable[] = [] + let adds: NonNullable[] = [] + const flush = (): void => { + for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { + const left = dels[at] + const right = adds[at] + rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) + } + dels = [] + adds = [] + } + for (const row of hunkRows(hunk)) { + if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) + else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) + else { + flush() + rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) + } + } + flush() + return rows +} + +/** + * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. + * @param hunks - served hunks. + * @returns the hunks with the last one shortened as needed, and whether anything was cut. + */ +export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { + let budget = MAX_RENDERED_LINES + const kept: WorkspaceDiffHunk[] = [] + for (const hunk of hunks) { + if (budget === 0) return { hunks: kept, truncated: true } + kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) + budget -= Math.min(budget, hunk.lines.length) + } + return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } +} + +/** The one-line fact about a text comparison worth stating above its hunks, if any. */ +function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { + if (!diff.before) return 'diff.created' + if (!diff.after) return 'diff.deleted' + if (diff.hunks.length === 0) return 'diff.unchanged' + return undefined +} + +/** + * Render a file comparison with the same states and highlighting in previews and review tabs. + * @param props - comparison state, layout choices, retry action, and localized copy. + * @returns the comparison or its loading, unavailable, or error state. + */ +export function FileDiff({ state, split, wrap, retry, t }: { + state: ChangesDiffState | undefined + split: boolean + wrap: boolean + retry: () => void +} & PropsLocale): ReactNode { + if (state === undefined || state === 'loading') return

{t('diff.loading')}

+ if (state === 'missing') return

{t('diff.missing')}

+ if (state === 'error') { + return
{t('diff.error')}
+ } + if (state.kind === 'binary') return

{t('diff.binary')}

+ if (state.kind === 'oversized') return

{t('diff.oversized')}

+ return +} + +/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ +function splitRowKind(row: SplitRow): DiffRow['kind'] { + return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' +} + +function hunkHeader(hunk: WorkspaceDiffHunk): string { + return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` +} + +/** + * The side-by-side view without wrapping: two columns that clip their long + * lines and scroll together on both axes, so a long line on one side never + * runs under the other and both sides show the same rows and columns of text. + * Every line is one fixed-height row, which keeps the sides aligned. + */ +function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { + const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + const columns = useRef>({ left: null, right: null }) + const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) + const follow = (side: 'left' | 'right') => (event: UIEvent): void => { + const peer = side === 'left' ? 'right' : 'left' + const other = columns.current[peer] + /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ + if (other === null) return + for (const axis of ['scrollLeft', 'scrollTop'] as const) { + const value = event.currentTarget[axis] + if (offsets.current[side][axis] === value) continue + offsets.current[side][axis] = value + other[axis] = value + // Record the browser-clamped offset so its scroll event cannot pull the source back. + offsets.current[peer][axis] = other[axis] + } + } + return ( +
+ {(['left', 'right'] as const).map(side => ( +
{ columns.current[side] = element }} onScroll={follow(side)}> + {paired.map((hunk, position) => ( +
+
{hunk.header}
+ {hunk.rows.map((row, at) => { + const cell = row[side] + const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) + return ( +
+ {cell?.no ?? ''} + +
+ ) + })} +
+ ))} +
+ ))} +
+ ) +} + +/** The hunks of a text comparison with their line numbers, unified or side by side. */ +function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { + const note = noteOf(diff) + const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) + const highlighter = useCodeHighlighter(languageForPath(diff.path)) + const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) + return ( +
+ {note !== undefined &&

{t(note)}

} + {diff.coarse &&

{t('diff.coarse')}

} + {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} + {split && !wrap ? : hunks.map((hunk, position) => { + const highlighted = highlights[position] + return
+
{hunkHeader(hunk)}
+ {split ? splitRows(hunk).map((row, at) => ( +
+ + {row.left?.no ?? ''} + + + + {row.right?.no ?? ''} + + +
+ )) : hunkRows(hunk).map((row, at) => ( +
+ {row.old ?? ''} + {row.new ?? ''} + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + +
+ ))} +
+ })} +
+ ) +} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 362f88ec9d..158b2495f5 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -3,30 +3,26 @@ * selected file's turn-start and turn-end comparison drawn unified or side by * side, wrapped or scrolling, and controls to open the file itself. */ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, UIEvent } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { - Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, + IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, - languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' -import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' -import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress } from '../changes.ts' import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import type { PresentedOpenController } from './present-open.ts' import type { ChangesReviewParams } from './review-definition.ts' import type { createReviewStore } from './review-store.ts' import type { NS } from './locales.ts' +import { FileDiff } from './FileDiff.tsx' import css from './ReviewTab.module.css' -/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */ -export const MAX_RENDERED_LINES = 5000 - const GROUPED = new Intl.NumberFormat('en-US') /** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */ @@ -47,125 +43,6 @@ export interface ReviewInjected { export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> -/** One drawn line of a hunk with its line numbers on each side. */ -export interface DiffRow { - kind: 'add' | 'del' | 'context' - old: number | undefined - new: number | undefined - text: string -} - -/** One side-by-side row: the old side, the new side, or both. */ -export interface SplitRow { - left?: { no: number; text: string; kind: 'del' | 'context' } - right?: { no: number; text: string; kind: 'add' | 'context' } -} - -interface HunkHighlights { - old: ReadonlyMap | undefined - new: ReadonlyMap | undefined -} - -function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { - const source = rows.flatMap((row) => { - const no = row[side] - return no === undefined ? [] : [{ no, text: row.text }] - }) - if (source.length === 0) return new Map() - const highlighted = highlighter(source.map(line => line.text).join('\n')) - if (highlighted === undefined) return undefined - return new Map(source.map((line, index) => { - // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. - return [line.no, highlighted[index] ?? []] - })) -} - -function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { - const rows = hunkRows(hunk) - return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } -} - -function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { - return - {spans === undefined ? text : spans.map((span, index) => {span.text})} - -} - -/** - * Number a hunk's lines: context lines count on both sides, deletions on the - * old side, additions on the new side. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { - let oldNo = hunk.oldStart - let newNo = hunk.newStart - return hunk.lines.map((line) => { - const text = line.slice(1) - switch (line[0]) { - case '+': return { kind: 'add', old: undefined, new: newNo++, text } - case '-': return { kind: 'del', old: oldNo++, new: undefined, text } - default: return { kind: 'context', old: oldNo++, new: newNo++, text } - } - }) -} - -/** - * Pair a hunk's lines for the side-by-side view: each run of deletions is - * aligned with the run of additions that follows it, row by row, and context - * lines sit on both sides. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { - const rows: SplitRow[] = [] - let dels: NonNullable[] = [] - let adds: NonNullable[] = [] - const flush = (): void => { - for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { - const left = dels[at] - const right = adds[at] - rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) - } - dels = [] - adds = [] - } - for (const row of hunkRows(hunk)) { - if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) - else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) - else { - flush() - rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) - } - } - flush() - return rows -} - -/** - * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. - * @param hunks - served hunks. - * @returns the hunks with the last one shortened as needed, and whether anything was cut. - */ -export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { - let budget = MAX_RENDERED_LINES - const kept: WorkspaceDiffHunk[] = [] - for (const hunk of hunks) { - if (budget === 0) return { hunks: kept, truncated: true } - kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) - budget -= Math.min(budget, hunk.lines.length) - } - return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } -} - -/** The one-line fact about a text comparison worth stating above its hunks, if any. */ -function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { - if (!diff.before) return 'diff.created' - if (!diff.after) return 'diff.deleted' - if (diff.hunks.length === 0) return 'diff.unchanged' - return undefined -} - /** The file index a navigation names, when it names one. */ function navigatedIndex(params: unknown): number | undefined { const index = (params as ChangesReviewParams | undefined)?.index @@ -275,124 +152,8 @@ export function ReviewTab({
{summaryState === 'loading' &&

{t('diff.loading')}

} {summaryState === 'missing' &&

{t('diff.missing')}

} - {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />}
) } - -/** The selected file's comparison, or the state that stands in for it. */ -function FileBody({ state, split, wrap, retry, t }: { - state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record ? V : never) : never - split: boolean - wrap: boolean - retry: () => void -} & PropsLocale): ReactNode { - if (state === undefined || state === 'loading') return

{t('diff.loading')}

- if (state === 'missing') return

{t('diff.missing')}

- if (state === 'error') { - return
{t('diff.error')}
- } - if (state.kind === 'binary') return

{t('diff.binary')}

- if (state.kind === 'oversized') return

{t('diff.oversized')}

- return -} - -/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ -function splitRowKind(row: SplitRow): DiffRow['kind'] { - return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' -} - -function hunkHeader(hunk: WorkspaceDiffHunk): string { - return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` -} - -/** - * The side-by-side view without wrapping: two columns that clip their long - * lines and scroll together on both axes, so a long line on one side never - * runs under the other and both sides show the same rows and columns of text. - * Every line is one fixed-height row, which keeps the sides aligned. - */ -function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { - const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) - const columns = useRef>({ left: null, right: null }) - const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) - const follow = (side: 'left' | 'right') => (event: UIEvent): void => { - const peer = side === 'left' ? 'right' : 'left' - const other = columns.current[peer] - /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ - if (other === null) return - for (const axis of ['scrollLeft', 'scrollTop'] as const) { - const value = event.currentTarget[axis] - if (offsets.current[side][axis] === value) continue - offsets.current[side][axis] = value - other[axis] = value - // Record the browser-clamped offset so its scroll event cannot pull the source back. - offsets.current[peer][axis] = other[axis] - } - } - return ( -
- {(['left', 'right'] as const).map(side => ( -
{ columns.current[side] = element }} onScroll={follow(side)}> - {paired.map((hunk, position) => ( -
-
{hunk.header}
- {hunk.rows.map((row, at) => { - const cell = row[side] - const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) - return ( -
- {cell?.no ?? ''} - -
- ) - })} -
- ))} -
- ))} -
- ) -} - -/** The hunks of a text comparison with their line numbers, unified or side by side. */ -function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { - const note = noteOf(diff) - const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) - const highlighter = useCodeHighlighter(languageForPath(diff.path)) - const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) - return ( -
- {note !== undefined &&

{t(note)}

} - {diff.coarse &&

{t('diff.coarse')}

} - {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} - {split && !wrap ? : hunks.map((hunk, position) => { - const highlighted = highlights[position] - return
-
{hunkHeader(hunk)}
- {split ? splitRows(hunk).map((row, at) => ( -
- - {row.left?.no ?? ''} - - - - {row.right?.no ?? ''} - - -
- )) : hunkRows(hunk).map((row, at) => ( -
- {row.old ?? ''} - {row.new ?? ''} - {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} - -
- ))} -
- })} -
- ) -} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index f000547756..886456e328 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -66,8 +66,9 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, + hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, showCodeDiff: ctx.settingsScope.developerTools.enabled }, + loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application), diff --git a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx new file mode 100644 index 0000000000..a78c38cbcb --- /dev/null +++ b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/** Hover dwell, lazy comparison reads, and Sidebar navigation from turn-tail rows. */ +import { useSyncExternalStore } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { ChangedFiles } from '../src/client/ChangedFiles.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' +import { changesDiffUrl } from '../src/changes.ts' +import { en } from '../src/client/locales.ts' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers() }) + +const sessionId = SessionId('hover-session') +const files = ['main.ts', 'second.ts'].map(path => ({ path, display: path, added: 1, deleted: 1 })) + +function mount() { + const diffs = new ChangesDiffStore() + const loadChangesDiff = vi.fn().mockResolvedValue(undefined) + const openReview = vi.fn() + const subscribe = diffs.state.subscribe.bind(diffs.state) + const getSnapshot = diffs.state.getSnapshot.bind(diffs.state) + const view = render( select(useSyncExternalStore(subscribe, getSnapshot))} />) + return { view, diffs, loadChangesDiff, openReview, row: screen.getByRole('button', { name: 'View changes to main.ts' }) } +} + +it('reads only after 500ms and renders the selected comparison in a single column', () => { + const { row, diffs, loadChangesDiff, openReview } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + expect(screen.getByText('Reading changes…')).toBeTruthy() + act(() => { diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-const value = 1', '+const value = 2'] }], + } }) }) + const preview = document.querySelector('[data-changes-hover-preview]')! + expect(preview.querySelector('[data-review-view="unified"]')).not.toBeNull() + expect(preview.querySelector('[data-diff-side]')).toBeNull() + expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1') + expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2') + expect(preview.textContent).toContain('/workspace/main.ts') + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(preview) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(preview.parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + act(() => { vi.advanceTimersByTime(1) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + expect(openReview).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('cancels a short hover and the pending dwell when unmounted', () => { + const { row, view, loadChangesDiff } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + fireEvent.pointerEnter(row) + view.unmount() + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() +}) + +it('previews each row independently, retries failed reads, and closes on Escape', () => { + const { row, diffs, loadChangesDiff } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: 'error', [changesDiffUrl(sessionId, 5, 1)]: 'missing' }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(screen.getByRole('button', { name: 'View changes to second.ts' })) + act(() => { vi.advanceTimersByTime(200) }) + act(() => { vi.advanceTimersByTime(300) }) + expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1) + expect(screen.getByText('/workspace/second.ts')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 831c32a74d..4756af65c6 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -26,6 +26,7 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' import type { ReviewInjected } from '../src/client/ReviewTab.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' @@ -40,10 +41,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const diffs = new ChangesDiffStore() const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} } return { SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, renderSlot: renderFileActions, + useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()), + loadChangesDiff: vi.fn((...args: Parameters) => diffs.load(...args)), useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), @@ -616,7 +620,7 @@ describe('ChangedFiles card', () => { fireEvent.click(expand) expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() - expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc') + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull() fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 2af2a12b4a..de97d56dac 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -18,8 +18,9 @@ import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { PresentedOpenController } from '../src/client/present-open.ts' import { - hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps, + ReviewTab, type ReviewInjected, type ReviewTabProps, } from '../src/client/ReviewTab.tsx' +import { hunkRows, MAX_RENDERED_LINES, renderedHunks, splitRows } from '../src/client/FileDiff.tsx' import { changesReviewDefinition } from '../src/client/review-definition.ts' import { createReviewStore } from '../src/client/review-store.ts' import { en, zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e9518efb50..499ec1d975 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: 6ba9e8f79b9b8f8473fec2094a724e9d371e0f4e -README.zh.md: 919659266ca729dfdaf50b00783cff065112ee07 +README.md: f639551b1ff008e226a9e9fc42784160c87b41cd +README.zh.md: 8054de52d2bd1dac6a2550e94906f4a15c269a72 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 6ba9e8f79b..f639551b1f 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,7 +72,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel caps its height at 420px and follows content and anchor resizing; Escape dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 919659266c..8054de52d2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..551b25c89a 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -52,3 +52,31 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +.preview { + display: flex; + flex-direction: column; + max-height: 420px; + padding: 0; + overflow: hidden; + border: 0; + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + box-shadow: var(--dsw-elevation-panel); + opacity: 1; + transition: opacity var(--dsh-hover-preview-fade) linear; +} + +@starting-style { + .preview { opacity: 0; } +} + +.preview[data-closing] { + opacity: 0; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .preview { transition: none; } +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 216df4c5d5..b08e5dd5ec 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,17 +1,24 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { CSSProperties, ReactNode, RefObject } from 'react' +import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' +/** Preview opacity transition and retained lifetime during dismissal. */ +const PREVIEW_FADE_MS = 100 + /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.disabled - suppress opening; turning true closes an open card. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, and 100ms opacity transitions. + * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. + * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. @@ -20,16 +27,19 @@ import css from './HoverCard.module.css' */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel, copiedLabel, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean - copyText?: string | undefined - copyLabel: string - copiedLabel: string -}) { + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -38,8 +48,11 @@ export function HoverCard({ const copyEpochRef = useRef(0) const copyingRef = useRef(false) const mountedRef = useRef(true) - const [open, setOpen] = useState(false) - const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') + const open = phase !== 'closed' + const closing = phase === 'closing' + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const positioned = pos !== null const [copied, setCopied] = useState(false) const clearCopied = useCallback(() => { @@ -54,8 +67,8 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() - setOpen(false) - }, [clearCopied]) + setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') + }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) @@ -66,7 +79,13 @@ export function HoverCard({ } } - // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!closing) return + const timer = setTimeout(() => { setPhase('closed') }, PREVIEW_FADE_MS) + return () => { clearTimeout(timer) } + }, [closing]) + + // Owner disabling mid-hover (menu opened, drag started) starts dismissal. useEffect(() => { if (!disabled) return clearTimer() @@ -87,6 +106,17 @@ export function HoverCard({ } }, []) + useEffect(() => { + if (!open || variant !== 'preview') return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + cancelClose() + close() + } + window.addEventListener('keydown', dismiss) + return () => { window.removeEventListener('keydown', dismiss) } + }, [open, variant, cancelClose, close]) + // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -97,29 +127,48 @@ export function HoverCard({ if (wrapper === null) return const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 + if (variant === 'preview') { + const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r + const width = Math.max(0, Math.min(bounds.width - 48, window.innerWidth - 16)) + const above = Math.max(0, r.top - 16) + const below = Math.max(0, window.innerHeight - r.bottom - 16) + const onTop = above >= Math.min(420, below) + const maxHeight = Math.min(420, onTop ? above : below) + setPos({ + left: Math.max(8, Math.min(bounds.left + 24, window.innerWidth - width - 8)), + top: onTop ? Math.max(8, r.top - Math.min(h, maxHeight) - 8) : r.bottom + 8, + width, maxHeight, + }) + return + } const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) } place() + const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + if (element !== null && element !== undefined) observer?.observe(element) + } window.addEventListener('scroll', place, true) window.addEventListener('resize', place) return () => { + observer?.disconnect() window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open]) + }, [open, variant, widthAnchorRef, positioned]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null) return + if (!open || pos === null || variant === 'preview') return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } - }, [open, pos]) + }, [open, pos, variant]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -139,8 +188,12 @@ export function HoverCard({ const card = open && pos !== null && (
{ setOpen(true) }, openDelayMs) + timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) }} onPointerLeave={() => { clearTimer() @@ -187,7 +240,7 @@ export function HoverCard({ if (open) armClose() }} // A press inside the anchor (row click, menu trigger) dismisses the - // card immediately, without waiting for the owner to flip `disabled`. + // card, without waiting for the owner to flip `disabled`. // Capture presses reach this handler from the card too — it is a React // child of the wrapper — but a press there starts a selection, so the // card must stay mounted under it (and the browser's click with it). diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 5bb6f5786e..7a2195949f 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -6,7 +6,7 @@ import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) -afterEach(() => { vi.useRealTimers() }) +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) /** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { @@ -18,6 +18,8 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number } function mount(props: { + widthAnchorRef?: { current: HTMLElement | null } + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -65,6 +67,90 @@ describe('HoverCard', () => { expect(card.style.top).toBe('40px') }) + it('insets a preview by 24px on each side and follows anchor resizing', () => { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.width).toBe('552px') + expect(card.style.left).toBe('124px') + expect(card.style.maxHeight).toBe('420px') + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 400, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.width).toBe('352px') + fireEvent.keyDown(wrapper, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('places previews below rows near the top and dismisses without anchor focus', () => { + const { wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.top).toBe('82px') + expect(card.style.maxHeight).toBe('420px') + fireEvent.keyDown(document.body, { key: 'a' }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('observes preview and container sizes until dismissal', () => { + let resize = () => {} + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe = observe + disconnect = disconnect + }) + const container = document.createElement('div') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 700 }) + const { wrapper, view } = mount({ variant: 'preview', widthAnchorRef: { current: container } }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(observe).toHaveBeenCalledWith(card) + expect(observe).toHaveBeenCalledWith(container) + expect(card.style.width).toBe('652px') + expect(card.style.left).toBe('34px') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 500 }) + act(() => { resize() }) + expect(card.style.width).toBe('452px') + const previousDisconnects = disconnect.mock.calls.length + view.unmount() + expect(disconnect.mock.calls.length).toBe(previousDisconnects + 1) + }) + + it('retains a dismissing preview for 100ms and cancels dismissal on re-entry', () => { + const { wrapper, view } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(card.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.pointerEnter(wrapper) + expect(card.hasAttribute('data-closing')).toBe(false) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerDown(wrapper) + expect(vi.getTimerCount()).toBe(0) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.keyDown(document.body, { key: 'Escape' }) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + it('honors a custom openDelayMs', () => { const { wrapper } = mount({ openDelayMs: 50 }) fireEvent.pointerEnter(wrapper) diff --git a/snapshots/web/changed-files-turn/hover.expected.md b/snapshots/web/changed-files-turn/hover.expected.md new file mode 100644 index 0000000000..b5bf46a909 --- /dev/null +++ b/snapshots/web/changed-files-turn/hover.expected.md @@ -0,0 +1 @@ +- text: 1 1 start 2 + done From 087dc65e89a9ec0cb6d54fb8c7573b063a2ddcad Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 15:50:45 +0800 Subject: [PATCH 13/36] feat(preview): render Excel workbooks in the browser --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- ...2026-09-16-browser-excel-preview.i18n.yaml | 6 + .../2026-09-16-browser-excel-preview.md | 31 ++ .../2026-09-16-browser-excel-preview.zh.md | 31 ++ THIRD_PARTY_NOTICES.md | 5 + apps/web/tests/document-preview.e2e.ts | 49 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 9 + docs/config-catalog.zh.md | 9 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 22 +- .../ui-sidebar-documentpreview/README.zh.md | 22 +- .../licenses/FortuneSheet.txt | 21 + .../ui-sidebar-documentpreview/package.json | 5 + .../src/client/excel/ExcelBody.module.css | 48 ++ .../src/client/excel/LazyExcelBody.tsx | 25 + .../src/client/excel/asset-imports.d.ts | 11 + .../src/client/excel/convert.ts | 209 ++++++++ .../src/client/excel/excel.tsx | 51 ++ .../src/client/excel/index.ts | 24 + .../src/client/excel/locales.ts | 33 ++ .../src/client/excel/parse.ts | 64 +++ .../src/client/excel/worker.ts | 9 + .../src/client/index.ts | 2 + .../ui-sidebar-documentpreview/src/config.ts | 16 +- .../tests/apply.client.spec.ts | 2 + .../tests/config.host.spec.ts | 6 +- .../tests/excel-body.client.spec.tsx | 80 +++ .../tests/excel-convert.client.spec.ts | 119 +++++ .../tests/excel-fixture.ts | 49 ++ .../tests/excel-parse.client.spec.ts | 99 ++++ .../tests/excel-registration.client.spec.ts | 40 ++ .../tests/excel-worker.client.spec.ts | 37 ++ .../tests/pdf-license-bundle.client.spec.ts | 22 +- .../tsdown.config.ts | 40 +- .../src/client/slot-catalog.ts | 1 + patches/@fortune-sheet__react@1.0.4.patch | 26 + pnpm-lock.yaml | 456 ++++++++++++++++++ pnpm-workspace.yaml | 1 + .../web/document-preview/document.expected.md | 10 +- 42 files changed, 1666 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md create mode 100644 packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts create mode 100644 patches/@fortune-sheet__react@1.0.4.patch diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index b40fa370ec..c8e12581a6 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -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: 9042e23b674d208af19b0829f4c6ec7961f2464c +2026-09-11-node-office-kit.zh.md: 2707b26ae1a25e8d8819142567201e28a768346a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 132ac8b3dd..9042e23b67 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate 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 Excel preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX 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. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index c7bdab2322..2707b26ae1 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](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;[浏览器 Excel 预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 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 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml new file mode 100644 index 0000000000..8d9f266392 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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: 914d772229ada1e5f84524460510779f7624d3bc +2026-09-16-browser-excel-preview.zh.md: 1022ea293a36dfc032c9152bbbbc5682b12f9a98 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md new file mode 100644 index 0000000000..914d772229 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -0,0 +1,31 @@ +# 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 with FortuneSheet and a package-local ExcelJS adapter. The adapter accepts bytes and limits and returns worksheet data; it has no React state setters, timers, or component references. ExcelJS owns OOXML parsing, while the adapter owns the display mapping. The existing complete-file reader retains Session authorization, file versions, reload, and transient bytes. + +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 with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. + +A disposable browser Worker contains parser CPU work. A compressed-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 [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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. The browser scenario covers the failure through the bundled ESM entry; the CommonJS entry receives the same fix. Recheck this patch 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. Legacy XLS receives explicit XLSX guidance. 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 fixtures 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. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md new file mode 100644 index 0000000000..1022ea293a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -0,0 +1,31 @@ +# 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 适配层打开 XLSX。适配层接收字节和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。ExcelJS 负责 OOXML 解析,适配层负责显示映射。现有完整文件读取器保留 Session 授权、文件版本、重新载入和临时字节。 + +预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空,并显示工作簿提示。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 + +独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查压缩字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 + +固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。浏览器场景通过打包后的 ESM 入口覆盖该故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 + +## 考虑过的替代方案 + +**采用 FortuneExcel 面向 React 的转换辅助函数。** 其组件状态设置器和渲染后尺寸调整把文件转换耦合到一种 UI 生命周期。本地适配层无需挂载工作簿即可测试,也不需要第二种中间工作簿模型。 + +**自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。 + +**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。旧版 XLS 显示明确的 XLSX 引导。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 + +## 影响 + +渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX fixture 覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2ad45f06c7..6348295f6a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ 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/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 | @@ -128,6 +129,7 @@ pnpm applies local patches to the following packages at install time, so shipped - `@earendil-works/pi-ai@0.85.1` — [`patches/@earendil-works__pi-ai@0.85.1.patch`](patches/@earendil-works__pi-ai@0.85.1.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) +- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -165,6 +167,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@electron/get`](https://github.com/electron/get) | MIT | | [`@electron/notarize`](https://github.com/electron/notarize) | MIT | +| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | @@ -206,8 +209,10 @@ External packages **directly declared** for development, tests, types, or toolin | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index adefac10ee..d7351609f8 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,6 +9,7 @@ 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 } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-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 +244,7 @@ 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()), ...['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 +753,37 @@ else process.exit(1); '- Plain-text option and viewer picker: hidden', ].join('\n')) - const spreadsheetStates: string[] = [] - 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`) - } - sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n')) + 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) + await excel.getByText('公式与格式', { exact: true }).click() + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') + expect(await excel.locator('.fortune-fx-input').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('unavailable.xls') + const legacyGuidance = 'Save this legacy Excel file as .xlsx to preview it.' + await preview.getByText(legacyGuidance, { exact: true }).waitFor() + await openFile('unavailable.xlsx') + const invalidExcel = 'This Excel file could not be opened. It may be damaged or password protected.' + await preview.getByText(invalidExcel, { exact: true }).waitFor() + sections.push([ + '## Browser Excel preview', '', + '- Opens without the Office conversion service', + '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', + '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', + '- Formula bar is read-only; PDF body and editing toolbar absent', + `- Legacy guidance: ${legacyGuidance}`, + `- Invalid XLSX: ${invalidExcel}`, + ].join('\n')) await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') @@ -958,12 +981,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() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c1cf209b2c..1db0fed05c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: 1fac327a8a50a09cf694f6fc31a0af1f031f596f -config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49 +config-catalog.md: 020bc3327493ab1c82b5cbb4b0742168ee8fb393 +config-catalog.zh.md: e25e7331601301972afa29dfc893f0e6f5dafe6d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fac327a8a..020bc33274 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -502,6 +502,15 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fbc043ab62..e25e733160 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -506,6 +506,15 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } ``` diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index faad4651ee..8ddc5d9ec2 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: 79c7253887cd580bdcc725054c0bbf5b2877e90b -README.zh.md: 77847e8ce7a9203a85fee360ec8a481e32e7e871 +README.md: b37a393b1a41d78198a5c2a7dfab786c42b3d770 +README.zh.md: c867d34c544434b2541bfd9ab1ae4fef45469a35 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 79c7253887..b37a393b1a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,13 +9,14 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks use the unsupported-preview state. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and XLSX receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents - [What it registers](#what-it-registers) - [Addresses](#addresses) - [How it reads](#how-it-reads) +- [Excel preview](#excel-preview) - [Office preview](#office-preview) - [Navigation](#navigation) - [Model Experience](#model-experience) @@ -62,10 +63,25 @@ Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. + +## Excel preview + +Open `.xlsx` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. The viewer retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. It displays saved formula results without recalculating; missing results receive a notice. Legacy `.xls` files require saving as `.xlsx`. Excel preview does not call the Office conversion service. + +Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits complement the Host's complete-file read limit; they do not cap browser process memory or decompression allocations. + +| Field | Default | Meaning | +|---|---|---| +| `excel.maxBytes` | `16777216` (16 MiB) | Maximum compressed XLSX bytes | +| `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | +| `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | + +The lazy Excel chunk bundles MIT-licensed FortuneSheet and ExcelJS. A package-local, React-independent adapter maps ExcelJS data to FortuneSheet cells. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. + ## Office preview -Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. `.xls` and `.xlsx` do not register a Sidebar renderer: they show the unsupported-preview message without requesting conversion and offer the system default application when the Host reports a desktop. The [Host provider](../../document/office-to-pdf/README.md) retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. +Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML. @@ -108,7 +124,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. -- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Excel workbooks are not previewed and instead offer the system default application on desktop Hosts. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). +- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 77847e8ce7..c867d34c54 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,13 +9,14 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和 XLSX 接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 - [注册了什么](#what-it-registers) - [地址](#addresses) - [怎么读](#how-it-reads) +- [Excel 预览](#excel-preview) - [Office 预览](#office-preview) - [导航](#navigation) - [模型体验](#model-experience) @@ -62,10 +63,25 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 + +## Excel 预览 + +直接在浏览器中打开 `.xlsx`,支持工作表标签、单元格选择、复制和只读公式栏。查看器保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。它显示已保存的公式结果而不重新计算;缺少结果时显示提示。旧版 `.xls` 文件需要另存为 `.xlsx`。Excel 预览不调用 Office 转换服务。 + +在同一个 `ui-sidebar-documentpreview` 条目上配置 `excel`。这些限制补充 Host 的完整文件读取限制,但不限制浏览器进程内存或解压分配量。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `excel.maxBytes` | `16777216`(16 MiB) | 最大压缩 XLSX 字节数 | +| `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | +| `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | + +惰性 Excel chunk 打包 MIT 许可的 FortuneSheet 和 ExcelJS。包内独立于 React 的适配层将 ExcelJS 数据映射为 FortuneSheet 单元格。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 + ## Office 预览 -将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。`.xls` 与 `.xlsx` 不注册 Sidebar 渲染器:它们不请求转换,直接显示不支持预览的提示;Host 报告存在桌面时,还提供用系统默认应用打开的控件。[Host 提供方](../../document/office-to-pdf/README.zh.md)仍为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 +将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 [Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。 @@ -108,7 +124,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 -- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。Excel 工作簿不提供预览,在桌面 Host 上改为提供用系统默认应用打开的控件。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 +- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 diff --git a/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt b/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt new file mode 100644 index 0000000000..065d1f9e7e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 2a30d10fac..ef9a2bad2b 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -69,11 +69,16 @@ "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@fortune-sheet/core": "1.0.4", + "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "dompurify": "^3.4.11", + "exceljs": "4.4.0", + "fast-xml-parser": "5.11.1", + "fflate": "^0.8.3", "pdfjs-dist": "6.3.289", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css new file mode 100644 index 0000000000..25d00bc7eb --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -0,0 +1,48 @@ +.body { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + white-space: normal; + font-family: var(--dsw-font, sans-serif); +} + +.workbook { + flex: 1; + min-height: 0; + min-width: 0; + color-scheme: light; +} + +.notice { + flex: none; + margin: 0; + padding: 6px 12px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.status { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + height: 100%; + padding: 16px; + color: var(--dsw-alias-label-secondary); +} + +.loadingIcon { + display: flex; + animation: turn 0.8s linear infinite; +} + +@keyframes turn { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .loadingIcon { animation: none; } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx new file mode 100644 index 0000000000..90cf288a01 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx @@ -0,0 +1,25 @@ +/** Load the spreadsheet renderer only when a supported workbook is opened. */ +import { lazy, Suspense, type ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { DocumentPreviewProps } from '../document/contract.ts' +import type { ExcelLimits } from './convert.ts' +import { hostFileOf } from '../rpc.ts' +import { LoadingIndicator } from '../LoadingIndicator.tsx' +import css from '../TextPreview.module.css' + +/** Document input plus parser limits and localized copy. */ +export type ExcelBodyProps = DocumentPreviewProps & PropsLocale<'sidebarExcel'> & { readonly limits: ExcelLimits } + +const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx')).ExcelBody })) + +/** + * Show legacy-format guidance or load the browser workbook renderer. + * @param props - Complete file bytes and standard document seats. + * @returns Localized loading state or Excel preview. + */ +export function LazyExcelBody(props: ExcelBodyProps): ReactNode { + if (/\.xls$/iu.test(hostFileOf(props.resourceAddress).path)) return

{props.t('legacy')}

+ return }> + + +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts new file mode 100644 index 0000000000..6f49c8ee5a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts @@ -0,0 +1,11 @@ +/** Build-owned parser Worker source embedded in the lazy Excel chunk. */ +declare module '*.ts?raw' { + const source: string + export default source +} + +/** FortuneSheet's stylesheet is scoped by the preview component. */ +declare module '@fortune-sheet/react/dist/index.css?inline' { + const source: string + export default source +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts new file mode 100644 index 0000000000..6ea4c81ca5 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -0,0 +1,209 @@ +/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ +import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' +import { update as formatNumber, type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' +import { XMLParser } from 'fast-xml-parser' + +/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ +export interface ExcelLimits { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum time allowed for one parser Worker. */ + timeoutMs: number +} + +/** Preview data and a count of formulas without saved results. */ +export interface ExcelPreview { + sheets: Sheet[] + missingResults: number +} + +/** Parser failures with locale-independent categories. */ +export class ExcelPreviewError extends Error { + /** @param code - User-actionable parser failure. */ + constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout', options?: ErrorOptions) { + super(code, options) + this.name = 'ExcelPreviewError' + } +} + +const BORDER_STYLES: Record = { + thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, + double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, + mediumDashDotDot: 11, slantDashDot: 12, thick: 13, +} +const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] + +/** + * Decode an XLSX into display cells, retaining formulas without recalculation. + * @param bytes - Complete borrowed workbook bytes. + * @param limits - File and matrix allocation limits. + * @returns Sheets ready for a read-only FortuneSheet workbook. + */ +export async function convertExcel(bytes: Uint8Array, limits: ExcelLimits): Promise { + if (bytes.byteLength > limits.maxBytes) throw new ExcelPreviewError('tooLarge') + const workbook = new ExcelJS.Workbook() + try { + await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + } catch (error) { + throw new ExcelPreviewError('invalid', { cause: error }) + } + if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') + let area = 0 + for (const sheet of workbook.worksheets) { + area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) + if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + } + const colors = themeColors(workbook) + let missingResults = 0 + let active = false + const sheets = workbook.worksheets.map((worksheet, order): Sheet => { + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], + } + const celldata: NonNullable = [] + for (const range of worksheet.model.merges) { + // ExcelJS normalizes merged ranges to two cell addresses. + const [start, end] = range.split(':') as [string, string] + const first = worksheet.getCell(start) + const last = worksheet.getCell(end) + const merge = { + r: Number(first.row) - 1, c: Number(first.col) - 1, + rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, + } + config.merge[`${merge.r}_${merge.c}`] = merge + } + // ExcelJS's declarations omit absent parsed collections and style fields. + const columns = worksheet.columns as ExcelJS.Column[] | null + ;(columns ?? []).forEach((column, index) => { + if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) + if (column.hidden) config.colhidden[index] = 0 + }) + worksheet.eachRow({ includeEmpty: true }, (row) => { + const r = row.number - 1 + const height = row.height as number | undefined + if (height !== undefined) config.rowlen[r] = height * 96 / 72 + if (row.hidden) config.rowhidden[r] = 0 + row.eachCell({ includeEmpty: true }, (cell) => { + const c = Number(cell.col) - 1 + const v = convertCell(cell, colors) + if (v.f !== undefined && v.v === undefined) missingResults += 1 + if (cell.isMerged) { + v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } + if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } + } + celldata.push({ r, c, v }) + const border = cell.border as Partial | undefined + if (border !== undefined) { + const value: Record = { row_index: r, col_index: c } + for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { + const edge = border[side] + if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } + } + config.borderInfo.push({ rangeType: 'cell', value }) + } + }) + }) + const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] + const visible = worksheet.state === 'visible' + const status = visible && !active ? 1 : 0 + active ||= visible + const sheet: Sheet = { + id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, + row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), + config, celldata, showGridLines: view?.showGridLines !== false, + defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + } + if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) + if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { + sheet.frozen = { + type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', + range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, + } + } + return sheet + }) + return { sheets, missingResults } +} + +function convertCell( + cell: Omit & Partial>, + colors: readonly (string | undefined)[], +): Cell { + const font = cell.font + const alignment = cell.alignment + const result: Cell = fontStyle(font, colors) + const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined + if (bg !== undefined) result.bg = bg + if (alignment !== undefined) { + if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 + result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 + result.tb = alignment.wrapText ? '2' : '1' + if (alignment.textRotation === 'vertical') result.tr = '3' + else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation + } + const value = cell.value + let display: ExcelJS.CellValue = value + if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + result.f = `=${cell.formula}` + display = value.result ?? null + } + if (display instanceof Date) { + // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. + result.v = display.getTime() / 86_400_000 + 25569 + } else if (display !== null && typeof display === 'object') { + if ('richText' in display) { + result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } + result.v = display.richText.map(run => run.text).join('') + } else if ('text' in display) result.v = display.text + else result.v = (display as ExcelJS.CellErrorValue).error + } else if (display !== null && display !== undefined) result.v = display + if (result.ht === undefined) result.ht = typeof result.v === 'number' ? 2 : typeof result.v === 'boolean' ? 0 : 1 + if (result.ct === undefined) result.ct = { fa: cell.numFmt || 'General', t: typeof result.v === 'number' ? 'n' : typeof result.v === 'boolean' ? 'b' : 's' } + const formatted = typeof result.v === 'number' ? formatNumber(cell.numFmt || 'General', result.v) as string | number : result.v + result.m = formatted === undefined ? '' : String(formatted) + return result +} + +function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { + if (font === undefined) return {} + const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } + if (font.name !== undefined) result.ff = font.name + if (font.size !== undefined) result.fs = font.size + const fc = colorOf(font.color, colors) + if (fc !== undefined) result.fc = fc + return result +} + +function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } + +function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { + if (color === undefined) return undefined + const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) + if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined + // ExcelJS retains OOXML tint even though its Color declaration omits it. + const tint: unknown = Reflect.get(color, 'tint') + if (typeof tint !== 'number' || tint === 0) return `#${rgb}` + return `#${[0, 2, 4].map((offset) => { + const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) + return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') + }).join('')}` +} + +function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { + const themes: unknown = workbook.model.themes + const xml = record(themes).theme1 + if (typeof xml !== 'string') return [] + const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) + const scheme = record(record(record(parsed).theme).themeElements).clrScheme + return THEME_ORDER.map((name) => { + const color = record(record(scheme)[name]) + const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] + return typeof value === 'string' ? value : undefined + }) +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? value as Record : {} +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx new file mode 100644 index 0000000000..f0be53ffb8 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -0,0 +1,51 @@ +/** Read-only spreadsheet surface backed by browser-parsed workbook data. */ +import { useEffect, useState, type ReactNode } from 'react' +import { Workbook } from '@fortune-sheet/react' +import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' +import type { ExcelPreview } from './convert.ts' +import type { ExcelBodyProps } from './LazyExcelBody.tsx' +import { parseExcel } from './parse.ts' +import css from './ExcelBody.module.css' + +type State = { data: Uint8Array } & ({ value: ExcelPreview } | { error: string }) +const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` + +/** + * Display stored spreadsheet values and formats without editing or recalculation. + * @param props - Complete workbook bytes, limits, and locale. + * @returns An isolated spreadsheet surface with cancellable loading. + */ +export function ExcelBody({ content, limits, t }: ExcelBodyProps): ReactNode { + const data = content.kind === 'bytes' ? content.data : undefined + const [state, setState] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + if (data === undefined) return + const controller = new AbortController() + setState(undefined) + void parseExcel(data, limits, controller.signal).then( + (value) => { if (!controller.signal.aborted) setState({ data, value }) }, + (error: unknown) => { if (!controller.signal.aborted) setState({ data, error: error instanceof Error ? error.message : 'invalid' }) }, + ) + return () => { controller.abort() } + }, [data, limits, attempt]) + if (data === undefined) return

{t('invalid')}

+ if (state?.data !== data) return + + + if ('error' in state) return
+ {t(state.error === 'tooLarge' ? 'tooLarge' : state.error === 'timeout' ? 'timeout' : 'invalid')} + +
+ return
+ +

{t('summary')}

+ {state.value.missingResults > 0 &&

{t('missingResults')}

} +
+ +
+
+} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts new file mode 100644 index 0000000000..2d85968a44 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts @@ -0,0 +1,24 @@ +/** Excel previews use ordinary authorized file bytes without Office conversion. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '../index.ts' +import type { Config } from '../../config.ts' +import { LazyExcelBody } from './LazyExcelBody.tsx' +import { en, zh } from './locales.ts' + +/** + * Register the browser Excel viewer and its lifecycle-owned slot. + * @param ctx - Preview and locale registries. + * @param limits - Resolved parser limits. + */ +export function apply(ctx: Context, limits: Config['excel']): void { + const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel' + ctx.effect(() => ctx.locale.register('sidebarExcel', { zh, en })) + const t = ctx.locale.bind('sidebarExcel') + ctx.effect(() => ctx.documentPreviews.register({ + id, extensions: ['xlsx', 'xls'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', + title: () => t('title'), loading: 'bytes-complete', wrap: false, + })) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ + name: 'sidebar.right.tab.document', key: id, locale: 'sidebarExcel', inject: () => ({ limits }), + }, LazyExcelBody))) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts new file mode 100644 index 0000000000..b25bd503a2 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -0,0 +1,33 @@ +/** Locale-owned Excel preview controls and parser feedback. */ +export const zh = { + title: 'Excel', language: 'zh', loading: '正在打开表格…', + invalid: '无法打开此 Excel 文件。文件可能已损坏或受密码保护。', + tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', + legacy: '请将此旧版 Excel 文件另存为 .xlsx 后预览。', + summary: '只读预览 · 显示已保存的公式结果', + limitations: '图表、图片、数据透视表和条件格式暂不显示。', + missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', + retry: '重试', +} satisfies Record + +/** Excel preview dictionary keys. */ +export type ExcelPreviewKey = keyof typeof zh + +/** English Excel preview copy. */ +export const en = { + title: 'Excel', language: 'en', loading: 'Opening spreadsheet…', + invalid: 'This Excel file could not be opened. It may be damaged or password protected.', + tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', + legacy: 'Save this legacy Excel file as .xlsx to preview it.', + summary: 'Read-only preview · Saved formula results', + limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', + missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', + retry: 'Retry', +} satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Excel preview status and third-party locale selection. */ + sidebarExcel: ExcelPreviewKey + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts new file mode 100644 index 0000000000..3c7eb83bb3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts @@ -0,0 +1,64 @@ +/** Browser lifetime for the isolated XLSX parser. */ +import workerSource from './worker.ts?raw' +import type { ExcelLimits, ExcelPreview } from './convert.ts' + +/** + * Parse workbook bytes in a disposable Worker, copying the retained preview buffer. + * @param bytes - Borrowed complete file bytes. + * @param limits - File, matrix, and elapsed-time limits. + * @param signal - Document body's lifetime. + * @returns Parsed sheets; rejects with a locale key on parser failures. + */ +export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal: AbortSignal): Promise { + signal.throwIfAborted() + if (bytes.byteLength > limits.maxBytes) return Promise.reject(new Error('tooLarge')) + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })) + let worker: Worker + try { + worker = new Worker(url, { name: 'dsh-excel' }) + } catch (error) { + URL.revokeObjectURL(url) + reject(new Error('invalid', { cause: error })) + return + } + const finish = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', abort) + worker.onmessage = null + worker.onerror = null + worker.onmessageerror = null + worker.terminate() + URL.revokeObjectURL(url) + } + const abort = (): void => { finish(); reject(new DOMException('Excel preview closed', 'AbortError')) } + const fail = (): void => { finish(); reject(new Error('invalid')) } + const timer = setTimeout(() => { finish(); reject(new Error('timeout')) }, limits.timeoutMs) + worker.onerror = fail + worker.onmessageerror = fail + worker.onmessage = (event: MessageEvent) => { + const message = event.data + if (typeof message !== 'object' || message === null || !('ok' in message)) { fail(); return } + if (message.ok === true && 'value' in message && validPreview(message.value)) { + finish() + resolve(message.value) + } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout'].includes(String(message.code))) { + finish() + reject(new Error(String(message.code))) + } else fail() + } + signal.addEventListener('abort', abort, { once: true }) + try { + const copy = bytes.slice() + worker.postMessage({ bytes: copy, limits }, [copy.buffer]) + } catch (error) { finish(); reject(new Error('invalid', { cause: error })) } + }) +} + +function validPreview(value: unknown): value is ExcelPreview { + if (typeof value !== 'object' || value === null || !('sheets' in value) || !('missingResults' in value)) return false + return typeof value.missingResults === 'number' && Number.isSafeInteger(value.missingResults) && value.missingResults >= 0 + && Array.isArray(value.sheets) && value.sheets.length > 0 + && value.sheets.every((sheet: unknown) => typeof sheet === 'object' && sheet !== null && 'name' in sheet && typeof sheet.name === 'string' + && 'celldata' in sheet && Array.isArray(sheet.celldata)) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts new file mode 100644 index 0000000000..408fa82b10 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts @@ -0,0 +1,9 @@ +/** One workbook per disposable parser Worker; formulas and external links are never executed. */ +import { convertExcel, ExcelPreviewError, type ExcelLimits } from './convert.ts' + +globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; limits: ExcelLimits }>) => { + void convertExcel(event.data.bytes, event.data.limits).then( + (value) => { globalThis.postMessage({ ok: true, value }) }, + (error: unknown) => { globalThis.postMessage({ ok: false, code: error instanceof ExcelPreviewError ? error.code : 'invalid' }) }, + ) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index 7c8b6907b2..f9b52dff5f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -37,6 +37,7 @@ import { apply as registerImage } from './image/index.ts' import { apply as registerPdf } from './pdf/index.ts' import { apply as registerCode } from './code/index.ts' import { apply as registerOffice } from './office/index.ts' +import { apply as registerExcel } from './excel/index.ts' import { Config } from '../config.ts' // Values stay package-private unless another package needs them; the plugin @@ -125,4 +126,5 @@ export function apply(ctx: ClientContext): void { registerPdf(ctx) registerCode(ctx) registerOffice(ctx, config.office) + registerExcel(ctx, config.excel) } diff --git a/packages/client/ui-sidebar-documentpreview/src/config.ts b/packages/client/ui-sidebar-documentpreview/src/config.ts index 41e947c82f..8949cf2e9f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/config.ts +++ b/packages/client/ui-sidebar-documentpreview/src/config.ts @@ -14,14 +14,28 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } /** Deployment limits applied before Office preview registration. */ -export const Config: z<{ office?: Partial }, Config> = z.object({ +export const Config: z<{ office?: Partial; excel?: Partial }, Config> = z.object({ office: z.object({ maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), maxCachedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(64 * 1024 * 1024), maxPending: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32), }), + excel: z.object({ + maxBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(16 * 1024 * 1024), + maxCells: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250_000), + timeoutMs: z.natural().min(1).max(2_147_483_647).default(15_000), + }), }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 5e82256d60..79963d3c42 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -28,6 +28,7 @@ import { HTML_BODY_ID } from '../src/client/html/index.ts' import { ImageBody } from '../src/client/image/ImageBody.tsx' import { IMAGE_BODY_ID } from '../src/client/image/index.ts' import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' import { en, zh } from '../src/client/locales.ts' @@ -99,6 +100,7 @@ describe('ui-sidebar-documentpreview apply', () => { ['sidebar.right.tab.document.action', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeFontAction], ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeBody], ['sidebar.right.tab.document.office.pdf', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarPdf', LazyPdfBody], + ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel', 'sidebarExcel', LazyExcelBody], ]) expect(registered[0]?.store).toBeDefined() expect(typeof registered[0]?.inject).toBe('function') diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 05e14300b5..6646008850 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -5,12 +5,14 @@ import { expect, it, onTestFinished } from 'vitest' import { Config } from '../src/config.ts' import * as host from '../src/index.ts' -it('exposes only Client reuse limits on the Host entry', () => { +it('exposes Client cache and spreadsheet limits on the Host entry', () => { expect(host.Config).toBe(Config) expect(Config({})).toEqual({ office: { maxCachedEntries: 8, maxCachedBytes: 64 * 1024 * 1024, maxPending: 8, maxReaders: 32, - } }) + }, excel: { maxBytes: 16 * 1024 * 1024, maxCells: 250_000, timeoutMs: 15_000 } }) for (const key of ['maxCachedEntries', 'maxCachedBytes']) expect(() => Config({ office: { [key]: 0 } })).toThrow() + for (const key of ['maxBytes', 'maxCells', 'timeoutMs']) expect(() => Config({ excel: { [key]: 0 } })).toThrow() + expect(() => Config({ excel: { timeoutMs: 2_147_483_648 } })).toThrow() }) it('embeds YAML cache settings in browser pages and withdraws them on disposal', async () => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx new file mode 100644 index 0000000000..ebdb006f5a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +/** Excel preview lifecycle and read-only renderer settings. */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { Config } from '../src/config.ts' +import { en } from '../src/client/excel/locales.ts' +import type { ExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' + +const mocked = vi.hoisted(() => ({ parse: vi.fn(), workbook: vi.fn((_props: unknown) => null) })) +vi.mock('../src/client/excel/parse.ts', () => ({ parseExcel: mocked.parse })) +vi.mock('@fortune-sheet/react', () => ({ Workbook: mocked.workbook })) +import { ExcelBody } from '../src/client/excel/excel.tsx' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' + +const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps +const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } +afterEach(() => { cleanup(); vi.resetAllMocks() }) + +it('shows loading then a workbook with editing and recalculation disabled', async () => { + mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) + const view = render() + expect(screen.getByRole('status', { name: en.loading })).toBeDefined() + await screen.findByText(en.summary) + expect(screen.getByText(en.missingResults)).toBeDefined() + expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) + const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + view.unmount() + expect(signal.aborted).toBe(true) +}) + +it('ignores a retired file result and cancels parsing when the bytes change', async () => { + let finish!: (value: unknown) => void + mocked.parse.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })).mockResolvedValueOnce(value) + const view = render() + const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + view.rerender() + await screen.findByText(en.summary) + expect(signal.aborted).toBe(true) + finish({ ...value, missingResults: 1 }) + await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) +}) + +it.each(['invalid', 'tooLarge', 'timeout', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { + mocked.parse.mockRejectedValueOnce(new Error(error)).mockResolvedValueOnce(value) + render() + const key = error === 'tooLarge' || error === 'timeout' ? error : 'invalid' + await screen.findByText(en[key]) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await screen.findByText(en.summary) + expect(mocked.parse).toHaveBeenCalledTimes(2) +}) + +it('rejects non-byte content and shows legacy XLS guidance without loading a parser', () => { + const view = render() + expect(screen.getByRole('alert').textContent).toBe(en.invalid) + view.rerender() + expect(screen.getByRole('alert').textContent).toBe(en.legacy) + expect(mocked.parse).not.toHaveBeenCalled() +}) + +it('loads the spreadsheet chunk for an XLSX file', async () => { + mocked.parse.mockResolvedValue(value) + render() + await screen.findByText(en.summary) +}) + +it('uses generic guidance for non-Error failures and ignores rejection after unmount', async () => { + mocked.parse.mockRejectedValueOnce('parser failure') + const view = render() + await screen.findByText(en.invalid) + view.unmount() + let reject!: (error: Error) => void + mocked.parse.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) + const pending = render() + const signal = mocked.parse.mock.calls[1]![2] as AbortSignal + pending.unmount() + reject(new Error('closed')) + await waitFor(() => { expect(signal.aborted).toBe(true) }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts new file mode 100644 index 0000000000..4f084ab7f3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -0,0 +1,119 @@ +/** XLSX formatting, cached formula results, and resource admission. */ +import ExcelJS from 'exceljs' +import { strToU8, unzipSync, zipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { convertExcel } from '../src/client/excel/convert.ts' +import { Config } from '../src/config.ts' +import { excelFixture } from './excel-fixture.ts' + +const limits = Config({}).excel + +describe('Excel conversion', () => { + it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { + const input = await excelFixture() + const { sheets, missingResults } = await convertExcel(input, limits) + expect(input.byteLength).toBeGreaterThan(0) + expect(sheets.map(sheet => sheet.name)).toEqual(['季度预算', '公式与格式', '隐藏页']) + const first = sheets[0]! + const cell = (r: number, c: number) => first.celldata!.find(value => value.r === r && value.c === c)!.v + expect(cell(0, 0)).toMatchObject({ v: 'DeepSeek · 项目预算', bl: 1, fs: 18, fc: '#FFFFFF', bg: '#3B5CCC', ht: 0, vt: 0, mc: { r: 0, c: 0, rs: 1, cs: 4 } }) + expect(cell(0, 1)).toMatchObject({ mc: { r: 0, c: 0 } }) + expect(cell(0, 1)).not.toHaveProperty('v') + expect(cell(2, 3)).toMatchObject({ f: '=C3/B3', v: 0.8, m: '80.0%', ct: { fa: '0.0%', t: 'n' } }) + expect(cell(4, 1)).toMatchObject({ f: '=SUM(B3:B4)', v: 57000, m: '57,000.00' }) + expect(first.config).toMatchObject({ rowlen: { 0: 48 }, columnlen: { 0: 173 }, merge: { '0_0': { rs: 1, cs: 4 } } }) + expect(first.config!.borderInfo).toContainEqual({ rangeType: 'cell', value: { row_index: 1, col_index: 0, b: { style: 1, color: '#DCE2ED' } } }) + expect(first.frozen).toEqual({ type: 'rangeBoth', range: { row_focus: 1, column_focus: 0 } }) + const second = sheets[1]! + expect(second.celldata).toEqual(expect.arrayContaining([ + expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), + expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), + expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) as unknown }), + expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) as unknown } }) as unknown }), + expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) as unknown }), + expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) as unknown }), + expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) as unknown }), + expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) as unknown }), + ])) + expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } }) + expect(sheets[2]).toMatchObject({ hide: 1, status: 0 }) + expect(missingResults).toBe(1) + }) + + it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { + await expect(convertExcel(new Uint8Array([1, 2, 3]), limits)).rejects.toMatchObject({ code: 'invalid' }) + const input = await excelFixture() + await expect(convertExcel(input, { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Sparse').getCell('Z1000').value = 1 + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) + }) + + it('opens an empty worksheet and rejects workbooks with no visible sheet', async () => { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Empty') + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]).toMatchObject({ name: 'Empty', row: 1, column: 1, status: 1 }) + workbook.getWorksheet('Empty')!.state = 'veryHidden' + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits)).rejects.toMatchObject({ code: 'invalid' }) + }) + + it.each([false, true])('retains dates for the 1904 epoch flag %s', async (date1904) => { + const workbook = new ExcelJS.Workbook() + workbook.properties.date1904 = date1904 + const cell = workbook.addWorksheet('Dates').getCell('A1') + cell.value = new Date('2024-02-29T12:00:00Z') + cell.numFmt = 'yyyy-mm-dd hh:mm' + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]!.celldata![0]!.v).toMatchObject({ m: '2024-02-29 12:00' }) + }) + + it('maps custom colors, tints, alignment, decoration, and default dimensions', async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet('Styles') + sheet.properties.defaultColWidth = 12 + sheet.getColumn(1).width = 0.5 + const light = { theme: 4, tint: 0.5 } + const dark = { theme: 4, tint: -0.5 } + sheet.getCell('A1').value = 'Light' + sheet.getCell('A1').font = { color: light, strike: true, underline: true } + sheet.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: dark } + sheet.getCell('A1').alignment = { horizontal: 'right', vertical: 'top', wrapText: true, textRotation: 'vertical' } + sheet.getCell('B1').value = 'Rotated' + sheet.getCell('B1').alignment = { horizontal: 'left', vertical: 'bottom', textRotation: 45 } + sheet.getCell('B1').border = { top: { style: 'double' } } + sheet.getCell('C1').value = 'Automatic color' + sheet.getCell('C1').font = { color: { indexed: 64 } as Partial } + sheet.getCell('D1').value = 'Invalid color' + sheet.getCell('D1').font = { color: { argb: 'invalid' } } + sheet.getCell('E1').value = 'Untinted' + sheet.getCell('E1').font = { color: { argb: 'FFFF0000', tint: 0 } as Partial } + sheet.getCell('F1').value = 42 + sheet.getCell('F1').alignment = { vertical: 'middle' } + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]).toMatchObject({ defaultColWidth: 89, config: { columnlen: { 0: 6 } } }) + const cells = result.sheets[0]!.celldata! + expect(cells[0]!.v).toMatchObject({ fc: '#a7c0de', bg: '#28415f', cl: 1, un: 1, ht: 2, vt: 1, tb: '2', tr: '3' }) + expect(cells[1]!.v).toMatchObject({ ht: 1, vt: 2, rt: 45 }) + expect(cells[2]!.v).not.toHaveProperty('fc') + expect(cells[3]!.v).not.toHaveProperty('fc') + expect(cells[4]!.v).toMatchObject({ fc: '#FF0000' }) + expect(cells[5]!.v).toMatchObject({ v: 42, ht: 2, vt: 0, ct: { t: 'n' } }) + }) + + it.each([{ ySplit: 2 }, { xSplit: 1 }, {}])('maps one-axis or empty freeze settings %j', async (freeze) => { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Frozen', { views: [{ state: 'frozen', ...freeze, showGridLines: false }] }).getCell('A1').value = 1 + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]!.showGridLines).toBe(false) + expect(result.sheets[0]!.frozen?.type).toBe('ySplit' in freeze ? 'rangeRow' : 'xSplit' in freeze ? 'rangeColumn' : undefined) + }) + + it('accepts workbooks without a theme and ignores absent theme colors', async () => { + const files = unzipSync(await excelFixture()) + delete files['xl/theme/theme1.xml'] + expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + files['xl/theme/theme1.xml'] = strToU8('') + expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts new file mode 100644 index 0000000000..41ecf38986 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -0,0 +1,49 @@ +/** Real XLSX fixture shared by adapter and shipped-profile browser tests. */ +import ExcelJS from 'exceljs' + +/** + * Build a styled, multi-sheet workbook with cached and unsupported formulas. + * @returns Complete XLSX file bytes. + */ +export async function excelFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet('季度预算', { views: [{ state: 'frozen', xSplit: 1, ySplit: 2 }] }) + sheet.columns = [{ width: 24 }, { width: 18 }, { width: 18 }, { width: 18 }] + sheet.mergeCells('A1:D1') + sheet.getCell('A1').value = 'DeepSeek · 项目预算' + sheet.getCell('A1').font = { name: 'Arial', size: 18, bold: true, color: { argb: 'FFFFFFFF' } } + sheet.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF3B5CCC' } } + sheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' } + sheet.getRow(1).height = 36 + sheet.getRow(2).values = ['项目', '预算', '支出', '执行率'] + sheet.getRow(3).values = ['设计', 12000, 9600, { formula: 'C3/B3', result: 0.8 }] + sheet.getRow(4).values = ['研发', 45000, 31500, { formula: 'C4/B4', result: 0.7 }] + sheet.getRow(5).values = ['合计', { formula: 'SUM(B3:B4)', result: 57000 }, { formula: 'SUM(C3:C4)', result: 41100 }, { formula: 'C5/B5', result: 41100 / 57000 }] + for (let row = 2; row <= 5; row += 1) { + sheet.getRow(row).height = 28 + for (let column = 1; column <= 4; column += 1) { + const cell = sheet.getCell(row, column) + cell.border = { bottom: { style: 'thin', color: { argb: 'FFDCE2ED' } } } + if (row === 2 || row === 5) { + cell.font = { bold: true, color: { argb: 'FF24345C' } } + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEDF1FC' } } + } + if (row > 2 && column > 1) cell.numFmt = column === 4 ? '0.0%' : '#,##0.00' + } + } + const details = workbook.addWorksheet('公式与格式') + details.getCell('A1').value = { formula: '_xlfn.XLOOKUP(1,{1},{42})', result: 42 } + details.getCell('A2').value = { formula: 'SUM(1,2)' } + details.getCell('B1').value = new Date('2026-09-16T00:00:00Z') + details.getCell('B1').numFmt = 'yyyy-mm-dd' + details.getCell('C1').value = { richText: [{ text: '富文本', font: { bold: true } }, { text: ' 示例', font: { italic: true } }] } + details.getCell('D1').value = { text: 'Link text', hyperlink: 'https://example.com' } + details.getCell('A3').value = { sharedFormula: 'A2', result: 3 } + details.getCell('A4').value = true + details.getCell('A5').value = { error: '#DIV/0!' } + details.getRow(7).height = 40 + details.getRow(7).hidden = true + details.getColumn(5).hidden = true + workbook.addWorksheet('隐藏页', { state: 'hidden' }).getCell('A1').value = 'Hidden data' + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts new file mode 100644 index 0000000000..bf57fe5b30 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts @@ -0,0 +1,99 @@ +/** Parser Worker allocation, cancellation, timeout, and message failures. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { parseExcel } from '../src/client/excel/parse.ts' +import { Config } from '../src/config.ts' + +const limits = Config({}).excel +const preview = { sheets: [{ name: 'Sheet', celldata: [] }], missingResults: 0 } +let instance: ParserWorker +const revokeUrl = vi.fn() + +class ParserWorker { + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onmessageerror: (() => void) | null = null + postMessage = vi.fn<(message: { bytes: Uint8Array }, transfer: Transferable[]) => void>() + terminate = vi.fn() +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('Worker', vi.fn(function () { instance = new ParserWorker(); return instance })) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:excel-test') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(revokeUrl) +}) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); revokeUrl.mockClear() }) + +it('transfers a private copy and releases the Worker and URL after parsing', async () => { + const input = new Uint8Array([1, 2, 3]) + const result = parseExcel(input, limits, new AbortController().signal) + const [message, transfer] = instance.postMessage.mock.calls[0]! + expect(message.bytes).toEqual(input) + expect(message.bytes).not.toBe(input) + expect(transfer).toEqual([message.bytes.buffer]) + instance.onmessage!({ data: { ok: true, value: preview } }) + await expect(result).resolves.toEqual(preview) + expect(instance.terminate).toHaveBeenCalledOnce() + expect(revokeUrl).toHaveBeenCalledWith('blob:excel-test') + expect(vi.getTimerCount()).toBe(0) +}) + +it('does not allocate a Worker for aborted or oversized input', async () => { + const worker = vi.fn() + vi.stubGlobal('Worker', worker) + expect(() => parseExcel(new Uint8Array(), limits, AbortSignal.abort())).toThrow() + await expect(parseExcel(new Uint8Array(2), { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') + expect(worker).not.toHaveBeenCalled() +}) + +it('cancels an active parser without retaining callbacks or timers', async () => { + const controller = new AbortController() + const result = parseExcel(new Uint8Array(), limits, controller.signal) + const rejection = expect(result).rejects.toThrow('closed') + controller.abort(new Error('closed')) + await rejection + expect(instance.terminate).toHaveBeenCalledOnce() + expect(instance.onmessage).toBeNull() + expect(vi.getTimerCount()).toBe(0) +}) + +it('terminates a parser that exceeds its time budget', async () => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const rejection = expect(result).rejects.toThrow('timeout') + await vi.advanceTimersByTimeAsync(limits.timeoutMs) + await rejection + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it.each([null, 7, {}, { ok: true, value: null }, { ok: true, value: { sheets: [], missingResults: 0 } }, + { ok: true, value: { sheets: [{}], missingResults: 0 } }, { ok: true, value: { ...preview, missingResults: -1 } }, + { ok: false, code: 'surprise' }, { ok: false, code: 'invalid' }])('rejects invalid Worker message %j', async (data) => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + instance.onmessage!({ data }) + await expect(result).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s', async (event) => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + instance[event]!() + await expect(result).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it('revokes the URL when Worker construction fails', async () => { + vi.stubGlobal('Worker', vi.fn(function () { throw new Error('unavailable') })) + await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + expect(revokeUrl).toHaveBeenCalledOnce() +}) + +it('terminates the Worker when posting the copied buffer fails', async () => { + vi.stubGlobal('Worker', vi.fn(function () { + instance = new ParserWorker() + instance.postMessage.mockImplementation(() => { throw new Error('transfer failed') }) + return instance + })) + await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts new file mode 100644 index 0000000000..ca4161c026 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -0,0 +1,40 @@ +/** Excel registration remains independent of the Office conversion service. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { Config } from '../src/config.ts' +import { apply } from '../src/client/excel/index.ts' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' +import { en, zh } from '../src/client/excel/locales.ts' + +it('registers complete binary reads and removes the slot and locale on disposal', async () => { + const ctx = new Context() + const registry = new DocumentPreviewRegistry() + const removeLocale = vi.fn() + const removeBody = vi.fn() + const registerLocale = vi.fn(() => removeLocale) + const register = vi.fn((_options: unknown, _component: unknown) => removeBody) + ctx.provide('documentPreviews', registry) + ctx.provide('locale', { register: registerLocale, bind: () => makeTranslate(en) } as never) + ctx.provide('slots', { inject: (_name: string, callback: () => () => void) => callback(), register } as never) + const limits = Config({}).excel + const fiber = ctx.plugin({ apply: (scope) => { apply(scope, limits) } }) + try { + await fiber.await() + for (const path of ['budget.XLSX', 'legacy.xls']) { + const candidate = registry.candidates(path)[0]! + expect(candidate).toMatchObject({ loading: 'bytes-complete', wrap: false, binaryExtensions: ['xlsx', 'xls'] }) + expect(candidate.title()).toBe('Excel') + expect(candidate.read).toBeUndefined() + expect(candidate.bodyId).toBeUndefined() + } + expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en }) + expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) as unknown }), LazyExcelBody) + const options = register.mock.calls[0]![0] as { inject: () => unknown } + expect(options.inject()).toEqual({ limits }) + } finally { await fiber.dispose() } + expect(registry.getSnapshot()).toEqual([]) + expect(removeLocale).toHaveBeenCalledOnce() + expect(removeBody).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts new file mode 100644 index 0000000000..606e7e30a6 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts @@ -0,0 +1,37 @@ +/** Worker entry translates parser failures into its private response protocol. */ +import { afterEach, expect, it, vi } from 'vitest' +import { Config } from '../src/config.ts' +import { excelFixture } from './excel-fixture.ts' + +afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); vi.doUnmock('../src/client/excel/convert.ts') }) + +it('returns parsed sheets and bounded failure categories', async () => { + const post = vi.fn() + vi.stubGlobal('postMessage', post) + vi.stubGlobal('onmessage', null) + await import('../src/client/excel/worker.ts') + const handle: (event: MessageEvent) => void = globalThis.onmessage! + const message = (bytes: Uint8Array, maxCells = 250_000) => { + handle({ data: { bytes, limits: { ...Config({}).excel, maxCells } } } as MessageEvent) + } + message(await excelFixture()) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ ok: true })) }) + message(new Uint8Array([1, 2])) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) + message(await excelFixture(), 1) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'tooLarge' }) }) +}) + +it('reports an unexpected parser failure as an invalid workbook', async () => { + const post = vi.fn() + vi.stubGlobal('postMessage', post) + vi.stubGlobal('onmessage', null) + vi.doMock('../src/client/excel/convert.ts', async (importOriginal) => { + const original = await importOriginal() + return { ...original, convertExcel: vi.fn().mockRejectedValue(new Error('parser crashed')) } + }) + await import('../src/client/excel/worker.ts') + const handle: (event: MessageEvent) => void = globalThis.onmessage! + handle({ data: { bytes: new Uint8Array(), limits: Config({}).excel } } as MessageEvent) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts index 5b9d775206..d583495e9e 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' const packageRoot = resolve(import.meta.dirname, '..') @@ -56,13 +57,32 @@ describe('published PDF.js licenses', () => { const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout) const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout) expect([...client.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1])) - .toEqual(['./client.pdf.js']) + .toEqual(['./client.pdf.js', './client.excel.js']) expect(client).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u) expect([...pdf.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1])) .toEqual([]) expect(client).not.toContain('//! Bundled PDF.js license notices') expect(client).not.toContain('/pdfjs-dist/') expect(pdf).toContain('//! Bundled PDF.js license notices') + const excel = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.excel.js'], packageRoot, task.timeout) + expect(excel).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u) + expect(client).not.toContain('FortuneSheet') + expect(excel).toContain('//! Bundled spreadsheet license notices') + expect(excel).toContain('Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd') + expect(excel).toContain('Permission is hereby granted, free of charge') + let initialized = false + runInNewContext(excel, { window: { __ModuleLoader__: { load: (registration: { + factory: (resolve: (specifier: string) => unknown) => { ExcelBody: unknown } + }) => { + const loaded = registration.factory((specifier) => { + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return {} + if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) as unknown + throw new Error(`Unexpected browser dependency: ${specifier}`) + }) + expect(typeof loaded.ExcelBody).toBe('function') + initialized = true + } } } }) + expect(initialized).toBe(true) const pdfRoot = dirname(require.resolve('pdfjs-dist/package.json')) for (const name of licenseNames) { const source = readFileSync(join(pdfRoot, name), 'utf8').trimEnd() diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index 742a5f46e3..950f7bb930 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -1,16 +1,25 @@ import { readFileSync, readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' -import type { UserConfig } from 'tsdown' +import { Rolldown, type UserConfig } from 'tsdown' import { clientBundle } from '../tsdown.client.ts' const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], { - clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : undefined, + clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() + : fileName.endsWith('client.excel.js') ? excelLicenseBanner() : undefined, }) const require = createRequire(import.meta.url) const workerSpecifier = 'pdfjs-dist/build/pdf.worker.min.mjs?raw' const workerModule = '\0dsh-pdf-worker.mjs' +/** FortuneSheet omits its repository license from the npm payload. */ +function excelLicenseBanner(): string { + const fortune = readFileSync(join(import.meta.dirname, 'licenses/FortuneSheet.txt'), 'utf8') + const excel = readFileSync(join(dirname(require.resolve('exceljs/package.json')), 'LICENSE'), 'utf8') + const xml = readFileSync(join(dirname(dirname(require.resolve('fast-xml-parser'))), 'LICENSE'), 'utf8') + return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') +} + /** License files for PDF.js and the data embedded beside its runtime. */ function pdfLicenseFiles(root: string): string[] { return ['LICENSE', ...['cmaps', 'standard_fonts', 'wasm'].flatMap(directory => @@ -53,10 +62,35 @@ const pdfWorker: NonNullable = [{ }, }] +/** Embed a self-contained browser parser without giving it a loader-module dependency. */ +const excelWorker: NonNullable = [{ + name: 'dsh-excel-worker-source', + resolveId(source) { return source === './worker.ts?raw' ? '\0dsh-excel-worker-source' : null }, + async load(id) { + if (id !== '\0dsh-excel-worker-source') return null + const worker = await Rolldown.rolldown({ + input: join(import.meta.dirname, 'src/client/excel/worker.ts'), platform: 'browser', + resolve: { mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, + transform: { define: { 'process.env.NODE_ENV': JSON.stringify('production') } }, + }) + try { + const result = await worker.generate({ format: 'iife', minify: true }) + const chunk = result.output[0] + if (chunk?.type !== 'chunk') throw new Error('Excel parser did not emit a JavaScript chunk') + for (const path of Object.keys(chunk.modules)) this.addWatchFile(path) + return `export default ${JSON.stringify(chunk.code)};` + } finally { await worker.close() } + }, +}] + export default (options: Parameters[0]): UserConfig[] => bundle(options).map(config => config.name?.endsWith('/client') === true ? { ...config, - plugins: [config.plugins, pdfWorker], + inputOptions: { + ...config.inputOptions, + resolve: { ...config.inputOptions?.resolve, mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, + }, + plugins: [config.plugins, pdfWorker, excelWorker], define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() }, } : config, ) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index af3043fd78..2954e2b935 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2953,6 +2953,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-documentpreview CodeBody', + 'client-ui-sidebar-documentpreview LazyExcelBody', 'client-ui-sidebar-documentpreview HtmlBody', 'client-ui-sidebar-documentpreview ImageBody', 'client-ui-sidebar-documentpreview MarkdownBody', diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch new file mode 100644 index 0000000000..dfbcb4cd74 --- /dev/null +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.esm.js b/dist/index.esm.js +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95393d9e8c 100644 +--- a/dist/index.esm.js ++++ b/dist/index.esm.js +@@ -921,7 +921,7 @@ var InputBox = function InputBox() { + return {}; + }, [context.luckysheetfile, context.currentSheetId, context.luckysheetCellUpdate, firstSelection]); + useLayoutEffect(function () { +- if (!context.allowEdit) { ++ if (!context.allowEdit && context.forceFormulaRef) { + setContext(function (ctx) { + var flowdata = getFlowdata(ctx); + if (!_.isNil(flowdata) && ctx.forceFormulaRef) { +diff --git a/dist/index.js b/dist/index.js +index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e659e229d21 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -932,7 +932,7 @@ var InputBox = function InputBox() { + return {}; + }, [context.luckysheetfile, context.currentSheetId, context.luckysheetCellUpdate, firstSelection]); + React.useLayoutEffect(function () { +- if (!context.allowEdit) { ++ if (!context.allowEdit && context.forceFormulaRef) { + setContext(function (ctx) { + var flowdata = core.getFlowdata(ctx); + if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a78e83473..c6ef213fde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 + '@fortune-sheet/react@1.0.4': a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4540,6 +4541,12 @@ importers: '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path + '@fortune-sheet/core': + specifier: 1.0.4 + version: 1.0.4 + '@fortune-sheet/react': + specifier: 1.0.4 + version: 1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4555,6 +4562,15 @@ importers: dompurify: specifier: ^3.4.11 version: 3.4.11 + exceljs: + specifier: 4.4.0 + version: 4.4.0 + fast-xml-parser: + specifier: 5.11.1 + version: 5.11.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 pdfjs-dist: specifier: 6.3.289 version: 6.3.289 @@ -14196,6 +14212,28 @@ packages: '@noble/hashes': optional: true + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@formulajs/formulajs@2.9.3': + resolution: {integrity: sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g==} + hasBin: true + + '@fortune-sheet/core@1.0.4': + resolution: {integrity: sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw==} + + '@fortune-sheet/formula-parser@0.2.13': + resolution: {integrity: sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw==} + + '@fortune-sheet/react@1.0.4': + resolution: {integrity: sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw==} + peerDependencies: + react: '>= 18.2' + react-dom: '>= 18.2' + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -14749,6 +14787,9 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@octokit/openapi-types@28.0.0': resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} @@ -15948,6 +15989,9 @@ packages: '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -15989,6 +16033,9 @@ packages: '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/regenerator-runtime@0.13.8': + resolution: {integrity: sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -16408,6 +16455,9 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + app-builder-lib@26.15.3: resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} @@ -16415,6 +16465,18 @@ packages: dmg-builder: 26.15.3 electron-builder-squirrel-windows: 26.15.3 + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -16530,12 +16592,23 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bessel@1.0.2: + resolution: {integrity: sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==} + engines: {node: '>=0.8'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -16545,6 +16618,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -16577,6 +16653,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -16587,12 +16666,20 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + builder-util-runtime@9.7.0: resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} @@ -16650,6 +16737,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -16762,6 +16852,10 @@ packages: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -16833,6 +16927,15 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -17378,6 +17481,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + execa@10.0.0: resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} engines: {node: '>=22'} @@ -17419,6 +17526,10 @@ packages: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -17440,6 +17551,10 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} + hasBin: true + fast-xml-parser@5.7.3: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true @@ -17578,6 +17693,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -17801,6 +17921,9 @@ packages: immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -17896,6 +18019,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -18068,6 +18194,9 @@ packages: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} + jstat@1.9.6: + resolution: {integrity: sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==} + jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -18103,6 +18232,10 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -18246,6 +18379,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -18253,16 +18389,49 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -18683,6 +18852,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -18691,6 +18864,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + numeral@2.0.6: + resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==} + oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} @@ -18826,6 +19002,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -19076,6 +19256,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -19088,6 +19271,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -19228,6 +19414,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -19462,6 +19652,9 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -19524,6 +19717,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -19578,6 +19774,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -19767,6 +19966,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -19800,6 +20002,11 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -20124,6 +20331,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -20186,6 +20397,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -21291,6 +21506,54 @@ snapshots: optionalDependencies: '@noble/hashes': 2.3.0 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@formulajs/formulajs@2.9.3': + dependencies: + bessel: 1.0.2 + jstat: 1.9.6 + + '@fortune-sheet/core@1.0.4': + dependencies: + '@fortune-sheet/formula-parser': 0.2.13 + dayjs: 1.11.21 + immer: 9.0.21 + lodash: 4.18.1 + numeral: 2.0.6 + uuid: 8.3.2 + + '@fortune-sheet/formula-parser@0.2.13': + dependencies: + '@formulajs/formulajs': 2.9.3 + tiny-emitter: 2.1.0 + + '@fortune-sheet/react@1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@fortune-sheet/core': 1.0.4 + '@types/regenerator-runtime': 0.13.8 + immer: 9.0.21 + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + regenerator-runtime: 0.14.1 + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 @@ -21794,6 +22057,8 @@ snapshots: '@nodable/entities@2.2.0': {} + '@nodable/entities@3.0.0': {} + '@octokit/openapi-types@28.0.0': {} '@octokit/openapi-webhooks-types@12.1.0': {} @@ -22775,6 +23040,8 @@ snapshots: '@types/node': 22.20.0 form-data: 4.0.6 + '@types/node@14.18.63': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -22818,6 +23085,8 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/regenerator-runtime@0.13.8': {} + '@types/responselike@1.0.3': dependencies: '@types/node': 22.20.0 @@ -23277,6 +23546,8 @@ snapshots: anynum@1.0.0: {} + anynum@1.0.1: {} + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): dependencies: '@electron/asar': 3.4.1 @@ -23325,6 +23596,42 @@ snapshots: transitivePeerDependencies: - supports-color + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + argparse@2.0.1: {} aria-query@5.3.0: @@ -23416,12 +23723,21 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bessel@1.0.2: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + birpc@2.9.0: {} birpc@4.0.0: {} @@ -23432,6 +23748,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} body-parser@2.3.0: @@ -23478,6 +23796,8 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -23486,6 +23806,8 @@ snapshots: dependencies: '@types/node': 22.20.0 + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -23496,6 +23818,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + builder-util-runtime@9.7.0: dependencies: debug: 4.4.3(supports-color@9.4.0) @@ -23564,6 +23888,10 @@ snapshots: chai@6.2.2: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -23645,6 +23973,13 @@ snapshots: compare-version@0.1.2: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -23729,6 +24064,13 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + cross-dirname@0.1.0: optional: true @@ -24411,6 +24753,18 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + execa@10.0.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -24489,6 +24843,11 @@ snapshots: dependencies: pure-rand: 8.4.0 + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -24506,6 +24865,15 @@ snapshots: path-expression-matcher: 1.5.0 xml-naming: 0.1.0 + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.2.0 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fast-xml-parser@5.7.3: dependencies: '@nodable/entities': 2.2.0 @@ -24665,6 +25033,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.6.3 + function-bind@1.1.2: {} functional-red-black-tree@1.0.1: {} @@ -24961,6 +25336,8 @@ snapshots: immer@10.2.0: {} + immer@9.0.21: {} + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} @@ -25020,6 +25397,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} + is-what@5.5.0: {} is-wsl@3.1.1: @@ -25180,6 +25559,8 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jstat@1.9.6: {} + jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -25234,6 +25615,10 @@ snapshots: lazy-val@1.0.5: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lefthook-darwin-arm64@2.1.9: optional: true @@ -25341,18 +25726,42 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + listenercount@1.0.1: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash-es@4.18.1: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} long@5.3.2: {} @@ -25943,6 +26352,8 @@ snapshots: dependencies: abbrev: 4.0.0 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} npm-run-path@6.0.0: @@ -25950,6 +26361,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + numeral@2.0.6: {} + oauth-sign@0.9.0: {} object-assign@4.1.1: {} @@ -26104,6 +26517,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-expression-matcher@1.6.2: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -26362,6 +26777,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -26370,6 +26789,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.12.2 + regenerator-runtime@0.14.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -26562,6 +26983,10 @@ snapshots: sax@1.6.1: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -26855,6 +27280,10 @@ snapshots: dependencies: anynum: 1.0.0 + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + stylis@4.4.0: {} sumchecker@3.0.1: @@ -26952,6 +27381,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-emitter@2.1.0: {} + tiny-inflate@1.0.3: {} tiny-typed-emitter@2.1.0: {} @@ -26998,6 +27429,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -27158,6 +27591,19 @@ snapshots: unpipe@1.0.0: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -27193,6 +27639,8 @@ snapshots: uuid@14.0.1: {} + uuid@8.3.2: {} + vary@1.1.2: {} verror@1.10.0: @@ -27541,6 +27989,8 @@ snapshots: xml-naming@0.1.0: {} + xml-naming@0.3.0: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: {} @@ -27598,6 +28048,12 @@ snapshots: yoctocolors@2.1.2: {} + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 58172c0252..fd9e8fd510 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -97,5 +97,6 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch + '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index a2489ba886..af06b3f429 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -72,10 +72,14 @@ - Binary text shown: false - Plain-text option and viewer picker: hidden -## Spreadsheet preview +## Browser Excel preview -- XLS: unsupported / Open in default app -- XLSX: unsupported / Open in default app +- Opens without the Office conversion service +- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted +- Cached XLOOKUP result copied: 42; typing leaves it unchanged +- Formula bar is read-only; PDF body and editing toolbar absent +- Legacy guidance: Save this legacy Excel file as .xlsx to preview it. +- Invalid XLSX: This Excel file could not be opened. It may be damaged or password protected. ## Unknown suffix From 30b6abad5c327e8a36c3b53d109aea2485de38ed Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:10:35 +0800 Subject: [PATCH 14/36] fix(preview): retain Excel worksheet selections --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 1 + .../src/client/excel/convert.ts | 5 +++ .../tests/excel-convert.client.spec.ts | 2 + patches/@fortune-sheet__react@1.0.4.patch | 44 ++++++++++++++++++- pnpm-lock.yaml | 6 +-- 8 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 8d9f266392..2517c700a5 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 914d772229ada1e5f84524460510779f7624d3bc -2026-09-16-browser-excel-preview.zh.md: 1022ea293a36dfc032c9152bbbbc5682b12f9a98 +2026-09-16-browser-excel-preview.md: 10de50d05d7daf3a94ccc9c93e619c212b6ce54e +2026-09-16-browser-excel-preview.zh.md: 5e702ce95196da3566d05b8bf1ff5aaf923f1585 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 914d772229..10de50d05d 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -16,7 +16,7 @@ Preview is read-only and retains formula text alongside saved results. It does n A disposable browser Worker contains parser CPU work. A compressed-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 [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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. The browser scenario covers the failure through the bundled ESM entry; the CommonJS entry receives the same fix. Recheck this patch when upgrading FortuneSheet. +The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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 CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 1022ea293a..5e702ce951 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -16,7 +16,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查压缩字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 -固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。浏览器场景通过打包后的 ESM 入口覆盖该故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 +固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 ## 考虑过的替代方案 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index d7351609f8..0f1b77a698 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -760,6 +760,7 @@ else process.exit(1); expect(await preview.locator('[data-pdf-preview]').count()).toBe(0) expect(await excel.locator('.fortune-toolbar').count()).toBe(0) await excel.getByText('公式与格式', { exact: true }).click() + 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('=_xlfn.XLOOKUP(1,{1},{42})') expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts index 6ea4c81ca5..40dc53b547 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -114,6 +114,11 @@ export async function convertExcel(bytes: Uint8Array, limits: Excel row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), config, celldata, showGridLines: view?.showGridLines !== false, defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + luckysheet_select_save: [{ + row: [0, (config.merge['0_0']?.rs ?? 1) - 1], + column: [0, (config.merge['0_0']?.cs ?? 1) - 1], + row_focus: 0, column_focus: 0, + }], } if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 4f084ab7f3..2008eafe53 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -24,7 +24,9 @@ describe('Excel conversion', () => { expect(first.config).toMatchObject({ rowlen: { 0: 48 }, columnlen: { 0: 173 }, merge: { '0_0': { rs: 1, cs: 4 } } }) expect(first.config!.borderInfo).toContainEqual({ rangeType: 'cell', value: { row_index: 1, col_index: 0, b: { style: 1, color: '#DCE2ED' } } }) expect(first.frozen).toEqual({ type: 'rangeBoth', range: { row_focus: 1, column_focus: 0 } }) + expect(first.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 3], row_focus: 0, column_focus: 0 }]) const second = sheets[1]! + expect(second.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }]) expect(second.celldata).toEqual(expect.arrayContaining([ expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch index dfbcb4cd74..dd76b04e78 100644 --- a/patches/@fortune-sheet__react@1.0.4.patch +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95393d9e8c 100644 +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb87459dbe7 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -921,7 +921,7 @@ var InputBox = function InputBox() { @@ -11,8 +11,28 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95 setContext(function (ctx) { var flowdata = getFlowdata(ctx); if (!_.isNil(flowdata) && ctx.forceFormulaRef) { +@@ -6908,7 +6908,8 @@ var SheetItem = function SheetItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); +@@ -10693,7 +10694,8 @@ var SheetListItem = function SheetListItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); diff --git a/dist/index.js b/dist/index.js -index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e659e229d21 100644 +index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c002653a92b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -932,7 +932,7 @@ var InputBox = function InputBox() { @@ -24,3 +44,23 @@ index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e65 setContext(function (ctx) { var flowdata = core.getFlowdata(ctx); if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { +@@ -6919,7 +6919,8 @@ var SheetItem = function SheetItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); +@@ -10704,7 +10705,8 @@ var SheetListItem = function SheetListItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ef213fde..8f8f5e478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/react@1.0.4': a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38 + '@fortune-sheet/react@1.0.4': 7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4546,7 +4546,7 @@ importers: version: 1.0.4 '@fortune-sheet/react': specifier: 1.0.4 - version: 1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -21544,7 +21544,7 @@ snapshots: '@formulajs/formulajs': 2.9.3 tiny-emitter: 2.1.0 - '@fortune-sheet/react@1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@fortune-sheet/react@1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@fortune-sheet/core': 1.0.4 '@types/regenerator-runtime': 0.13.8 From e70fa894002d500d36e559360bba6a432d744af4 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 17:27:27 +0800 Subject: [PATCH 15/36] feat(preview): support xls csv and tsv --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 8 +- .../2026-09-16-browser-excel-preview.zh.md | 8 +- THIRD_PARTY_NOTICES.md | 9 +- apps/web/tests/document-preview.e2e.ts | 63 ++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 10 +- .../ui-sidebar-documentpreview/README.zh.md | 10 +- .../ui-sidebar-documentpreview/package.json | 6 +- .../src/client/excel/LazyExcelBody.tsx | 12 +- .../src/client/excel/convert.ts | 223 ++---------------- .../src/client/excel/delimited.ts | 43 ++++ .../src/client/excel/error.ts | 8 + .../src/client/excel/excel.tsx | 24 +- .../src/client/excel/format.ts | 16 ++ .../src/client/excel/index.ts | 2 +- .../src/client/excel/locales.ts | 16 +- .../src/client/excel/model.ts | 42 ++++ .../src/client/excel/parse.ts | 14 +- .../src/client/excel/worker.ts | 9 +- .../src/client/excel/xls.ts | 98 ++++++++ .../src/client/excel/xlsx.ts | 182 ++++++++++++++ .../ui-sidebar-documentpreview/src/config.ts | 4 +- .../tests/excel-body.client.spec.tsx | 38 +-- .../tests/excel-convert.client.spec.ts | 38 +-- .../tests/excel-formats.client.spec.ts | 140 +++++++++++ .../tests/excel-parse.client.spec.ts | 18 +- .../tests/excel-registration.client.spec.ts | 9 +- .../tests/excel-worker.client.spec.ts | 4 +- .../tests/pdf-license-bundle.client.spec.ts | 11 +- .../tests/xls-fixture.ts | 41 ++++ .../tsdown.config.ts | 15 +- pnpm-lock.yaml | 29 +++ .../web/document-preview/document.expected.md | 11 +- 40 files changed, 847 insertions(+), 342 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/format.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c8e12581a6..9f2569bdfb 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -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: 9042e23b674d208af19b0829f4c6ec7961f2464c -2026-09-11-node-office-kit.zh.md: 2707b26ae1a25e8d8819142567201e28a768346a +2026-09-11-node-office-kit.md: 8a974903f91c75d111a798bd9797120fef547f9f +2026-09-11-node-office-kit.zh.md: f4dd249c8e5d098308bf8f4cce7fcea1202a9efb diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 9042e23b67..8a974903f9 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate 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. Client PDF previews use this path for Word and PowerPoint; [browser Excel preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX 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. +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. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 2707b26ae1..f4dd249c8e 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](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 PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器 Excel 预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 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 会刷新元数据。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 2517c700a5..5e63afa914 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 10de50d05d7daf3a94ccc9c93e619c212b6ce54e -2026-09-16-browser-excel-preview.zh.md: 5e702ce95196da3566d05b8bf1ff5aaf923f1585 +2026-09-16-browser-excel-preview.md: 6a33fbccdace9172fcee0863519b5c05d0595c7b +2026-09-16-browser-excel-preview.zh.md: 190102ed86925b28dea434d3c2f3ded4de11ddf6 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 10de50d05d..6a33fbccda 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -10,11 +10,11 @@ PDF conversion loses spreadsheet navigation and the relationship between a formu ## Decision -The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX with FortuneSheet and a package-local ExcelJS adapter. The adapter accepts bytes and limits and returns worksheet data; it has no React state setters, timers, or component references. ExcelJS owns OOXML parsing, while the adapter owns the display mapping. The existing complete-file reader retains Session authorization, file versions, reload, and transient bytes. +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 with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. -A disposable browser Worker contains parser CPU work. A compressed-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. +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 [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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 CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. @@ -24,8 +24,8 @@ The pinned FortuneSheet React dependency carries a [small patch](../../../../pat **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. Legacy XLS receives explicit XLSX guidance. 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. +**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 fixtures 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. +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. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 5e702ce951..190102ed86 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -10,11 +10,11 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 ## 决策 -[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS 适配层打开 XLSX。适配层接收字节和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。ExcelJS 负责 OOXML 解析,适配层负责显示映射。现有完整文件读取器保留 Session 授权、文件版本、重新载入和临时字节。 +[文档预览](../../../../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 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 +独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 @@ -24,8 +24,8 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 **自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。 -**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。旧版 XLS 显示明确的 XLSX 引导。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 +**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 ## 影响 -渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX fixture 覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。 +渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX/XLS fixture 和分隔文本用例覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。该场景还验证 XLS 工作表切换,以及 CSV/TSV 字面值复制、纯文本切换和重新载入。由于公共 npm 仓库版本过旧,SheetJS CE 固定使用官方发布的 tarball;其 Apache-2.0 许可证沿用现有宽松许可证及打包声明规则。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6348295f6a..820d843095 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ 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 | @@ -80,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 | @@ -104,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 | @@ -121,6 +125,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 | @@ -167,7 +172,6 @@ External packages **directly declared** for development, tests, types, or toolin | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@electron/get`](https://github.com/electron/get) | MIT | | [`@electron/notarize`](https://github.com/electron/notarize) | MIT | -| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | @@ -185,6 +189,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 | @@ -209,10 +214,8 @@ External packages **directly declared** for development, tests, types, or toolin | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | -| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | -| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 0f1b77a698..2360bf6c35 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vit import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' import { excelFixture } 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' @@ -245,6 +246,9 @@ else process.exit(1); ...[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, '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])), ]) @@ -770,20 +774,63 @@ else process.exit(1); 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('unavailable.xls') - const legacyGuidance = 'Save this legacy Excel file as .xlsx to preview it.' - await preview.getByText(legacyGuidance, { exact: true }).waitFor() - await openFile('unavailable.xlsx') - const invalidExcel = 'This Excel file could not be opened. It may be damaged or password protected.' - await preview.getByText(invalidExcel, { exact: true }).waitFor() + 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') + 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}`) + await preview.getByText(invalidExcel, { exact: true }).waitFor() + } sections.push([ '## Browser Excel preview', '', '- Opens without the Office conversion service', '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', '- Formula bar is read-only; PDF body and editing toolbar absent', - `- Legacy guidance: ${legacyGuidance}`, - `- Invalid XLSX: ${invalidExcel}`, + '- 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') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1db0fed05c..3e8a9ce6d2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: 020bc3327493ab1c82b5cbb4b0742168ee8fb393 -config-catalog.zh.md: e25e7331601301972afa29dfc893f0e6f5dafe6d +config-catalog.md: cf76e5aaf35415088cbb35df689a278fb2ddccf7 +config-catalog.zh.md: 04db740aa06ca6ab47e819445303436505a3e480 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 020bc33274..cf76e5aaf3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -502,9 +502,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e25e733160..04db740aa0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -506,9 +506,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 8ddc5d9ec2..01384a6bee 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: b37a393b1a41d78198a5c2a7dfab786c42b3d770 -README.zh.md: c867d34c544434b2541bfd9ab1ae4fef45469a35 +README.md: d4392dc889b1e1cb052412900fe719174aabb508 +README.zh.md: b05257bcf3f816b6d1040a6d5489a545d6d25d5d diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index b37a393b1a..d4392dc889 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and XLSX receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and spreadsheets receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; spreadsheets open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents @@ -66,17 +66,19 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. The viewer retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. It displays saved formula results without recalculating; missing results receive a notice. Legacy `.xls` files require saving as `.xlsx`. Excel preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results receive a notice. Spreadsheet preview does not call the Office conversion service. + +CSV and TSV default to the spreadsheet viewer and also offer Plain text. Commas and tabs delimit their fields respectively; quoted separators, escaped quotes, multiline fields, empty fields, and unequal row lengths are supported. The first row remains data. Values remain literal strings, including leading zeros, dates, booleans, and formula-looking text. Text files accept UTF-8 or BOM-marked UTF-16; invalid encoding receives conversion guidance. Malformed quoted fields fail the table preview rather than silently dropping data. Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits complement the Host's complete-file read limit; they do not cap browser process memory or decompression allocations. | Field | Default | Meaning | |---|---|---| -| `excel.maxBytes` | `16777216` (16 MiB) | Maximum compressed XLSX bytes | +| `excel.maxBytes` | `16777216` (16 MiB) | Maximum source file bytes | | `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | | `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | -The lazy Excel chunk bundles MIT-licensed FortuneSheet and ExcelJS. A package-local, React-independent adapter maps ExcelJS data to FortuneSheet cells. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. +The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ## Office preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index c867d34c54..b05257bcf3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和 XLSX 接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和表格接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;表格在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 @@ -66,17 +66,19 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`,支持工作表标签、单元格选择、复制和只读公式栏。查看器保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。它显示已保存的公式结果而不重新计算;缺少结果时显示提示。旧版 `.xls` 文件需要另存为 `.xlsx`。Excel 预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。 + +CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。 在同一个 `ui-sidebar-documentpreview` 条目上配置 `excel`。这些限制补充 Host 的完整文件读取限制,但不限制浏览器进程内存或解压分配量。 | 字段 | 默认值 | 含义 | |---|---|---| -| `excel.maxBytes` | `16777216`(16 MiB) | 最大压缩 XLSX 字节数 | +| `excel.maxBytes` | `16777216`(16 MiB) | 最大源文件字节数 | | `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | | `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | -惰性 Excel chunk 打包 MIT 许可的 FortuneSheet 和 ExcelJS。包内独立于 React 的适配层将 ExcelJS 数据映射为 FortuneSheet 单元格。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 +惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 ## Office 预览 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index ef9a2bad2b..e28dbd9900 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -72,6 +73,7 @@ "@fortune-sheet/core": "1.0.4", "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.1.0", + "@types/papaparse": "^5.3.16", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", @@ -79,9 +81,11 @@ "exceljs": "4.4.0", "fast-xml-parser": "5.11.1", "fflate": "^0.8.3", + "papaparse": "5.5.3", "pdfjs-dist": "6.3.289", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx index 90cf288a01..8657e67114 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx @@ -2,7 +2,8 @@ import { lazy, Suspense, type ReactNode } from 'react' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { DocumentPreviewProps } from '../document/contract.ts' -import type { ExcelLimits } from './convert.ts' +import { excelFormat, type ExcelFormat } from './format.ts' +import type { ExcelLimits } from './model.ts' import { hostFileOf } from '../rpc.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' import css from '../TextPreview.module.css' @@ -10,16 +11,19 @@ import css from '../TextPreview.module.css' /** Document input plus parser limits and localized copy. */ export type ExcelBodyProps = DocumentPreviewProps & PropsLocale<'sidebarExcel'> & { readonly limits: ExcelLimits } +/** Lazily loaded renderer input with the filename-derived parser choice. */ +export type LoadedExcelBodyProps = ExcelBodyProps & { readonly format: ExcelFormat } + const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx')).ExcelBody })) /** - * Show legacy-format guidance or load the browser workbook renderer. + * Load the browser spreadsheet renderer for every registered format. * @param props - Complete file bytes and standard document seats. * @returns Localized loading state or Excel preview. */ export function LazyExcelBody(props: ExcelBodyProps): ReactNode { - if (/\.xls$/iu.test(hostFileOf(props.resourceAddress).path)) return

{props.t('legacy')}

+ const format = excelFormat(hostFileOf(props.resourceAddress).path) return }> - + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts index 40dc53b547..0ad1e5b9a7 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -1,214 +1,21 @@ -/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ -import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' -import { update as formatNumber, type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' -import { XMLParser } from 'fast-xml-parser' - -/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ -export interface ExcelLimits { - /** Maximum compressed workbook bytes. */ - maxBytes: number - /** Maximum combined rectangular cell area across worksheets. */ - maxCells: number - /** Maximum time allowed for one parser Worker. */ - timeoutMs: number -} - -/** Preview data and a count of formulas without saved results. */ -export interface ExcelPreview { - sheets: Sheet[] - missingResults: number -} - -/** Parser failures with locale-independent categories. */ -export class ExcelPreviewError extends Error { - /** @param code - User-actionable parser failure. */ - constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout', options?: ErrorOptions) { - super(code, options) - this.name = 'ExcelPreviewError' - } -} - -const BORDER_STYLES: Record = { - thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, - double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, - mediumDashDotDot: 11, slantDashDot: 12, thick: 13, -} -const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] +/** Format-specific parsing into one read-only FortuneSheet representation. */ +import type { ExcelFormat } from './format.ts' +import { ExcelPreviewError } from './error.ts' +import type { ExcelLimits, ExcelPreview } from './model.ts' +import { convertXlsx } from './xlsx.ts' +import { convertXls } from './xls.ts' +import { convertDelimited } from './delimited.ts' /** - * Decode an XLSX into display cells, retaining formulas without recalculation. - * @param bytes - Complete borrowed workbook bytes. + * Decode spreadsheet bytes without recalculating saved formulas. + * @param bytes - Borrowed complete file bytes. + * @param format - Format selected by the registered filename suffix. * @param limits - File and matrix allocation limits. - * @returns Sheets ready for a read-only FortuneSheet workbook. + * @returns Display sheets and missing formula-cache count. */ -export async function convertExcel(bytes: Uint8Array, limits: ExcelLimits): Promise { +export async function convertExcel(bytes: Uint8Array, format: ExcelFormat, limits: ExcelLimits): Promise { if (bytes.byteLength > limits.maxBytes) throw new ExcelPreviewError('tooLarge') - const workbook = new ExcelJS.Workbook() - try { - await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) - } catch (error) { - throw new ExcelPreviewError('invalid', { cause: error }) - } - if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') - let area = 0 - for (const sheet of workbook.worksheets) { - area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) - if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') - } - const colors = themeColors(workbook) - let missingResults = 0 - let active = false - const sheets = workbook.worksheets.map((worksheet, order): Sheet => { - const config: Required> = { - merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], - } - const celldata: NonNullable = [] - for (const range of worksheet.model.merges) { - // ExcelJS normalizes merged ranges to two cell addresses. - const [start, end] = range.split(':') as [string, string] - const first = worksheet.getCell(start) - const last = worksheet.getCell(end) - const merge = { - r: Number(first.row) - 1, c: Number(first.col) - 1, - rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, - } - config.merge[`${merge.r}_${merge.c}`] = merge - } - // ExcelJS's declarations omit absent parsed collections and style fields. - const columns = worksheet.columns as ExcelJS.Column[] | null - ;(columns ?? []).forEach((column, index) => { - if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) - if (column.hidden) config.colhidden[index] = 0 - }) - worksheet.eachRow({ includeEmpty: true }, (row) => { - const r = row.number - 1 - const height = row.height as number | undefined - if (height !== undefined) config.rowlen[r] = height * 96 / 72 - if (row.hidden) config.rowhidden[r] = 0 - row.eachCell({ includeEmpty: true }, (cell) => { - const c = Number(cell.col) - 1 - const v = convertCell(cell, colors) - if (v.f !== undefined && v.v === undefined) missingResults += 1 - if (cell.isMerged) { - v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } - if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } - } - celldata.push({ r, c, v }) - const border = cell.border as Partial | undefined - if (border !== undefined) { - const value: Record = { row_index: r, col_index: c } - for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { - const edge = border[side] - if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } - } - config.borderInfo.push({ rangeType: 'cell', value }) - } - }) - }) - const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] - const visible = worksheet.state === 'visible' - const status = visible && !active ? 1 : 0 - active ||= visible - const sheet: Sheet = { - id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, - row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), - config, celldata, showGridLines: view?.showGridLines !== false, - defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, - luckysheet_select_save: [{ - row: [0, (config.merge['0_0']?.rs ?? 1) - 1], - column: [0, (config.merge['0_0']?.cs ?? 1) - 1], - row_focus: 0, column_focus: 0, - }], - } - if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) - if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { - sheet.frozen = { - type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', - range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, - } - } - return sheet - }) - return { sheets, missingResults } -} - -function convertCell( - cell: Omit & Partial>, - colors: readonly (string | undefined)[], -): Cell { - const font = cell.font - const alignment = cell.alignment - const result: Cell = fontStyle(font, colors) - const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined - if (bg !== undefined) result.bg = bg - if (alignment !== undefined) { - if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 - result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 - result.tb = alignment.wrapText ? '2' : '1' - if (alignment.textRotation === 'vertical') result.tr = '3' - else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation - } - const value = cell.value - let display: ExcelJS.CellValue = value - if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { - result.f = `=${cell.formula}` - display = value.result ?? null - } - if (display instanceof Date) { - // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. - result.v = display.getTime() / 86_400_000 + 25569 - } else if (display !== null && typeof display === 'object') { - if ('richText' in display) { - result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } - result.v = display.richText.map(run => run.text).join('') - } else if ('text' in display) result.v = display.text - else result.v = (display as ExcelJS.CellErrorValue).error - } else if (display !== null && display !== undefined) result.v = display - if (result.ht === undefined) result.ht = typeof result.v === 'number' ? 2 : typeof result.v === 'boolean' ? 0 : 1 - if (result.ct === undefined) result.ct = { fa: cell.numFmt || 'General', t: typeof result.v === 'number' ? 'n' : typeof result.v === 'boolean' ? 'b' : 's' } - const formatted = typeof result.v === 'number' ? formatNumber(cell.numFmt || 'General', result.v) as string | number : result.v - result.m = formatted === undefined ? '' : String(formatted) - return result -} - -function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { - if (font === undefined) return {} - const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } - if (font.name !== undefined) result.ff = font.name - if (font.size !== undefined) result.fs = font.size - const fc = colorOf(font.color, colors) - if (fc !== undefined) result.fc = fc - return result -} - -function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } - -function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { - if (color === undefined) return undefined - const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) - if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined - // ExcelJS retains OOXML tint even though its Color declaration omits it. - const tint: unknown = Reflect.get(color, 'tint') - if (typeof tint !== 'number' || tint === 0) return `#${rgb}` - return `#${[0, 2, 4].map((offset) => { - const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) - return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') - }).join('')}` -} - -function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { - const themes: unknown = workbook.model.themes - const xml = record(themes).theme1 - if (typeof xml !== 'string') return [] - const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) - const scheme = record(record(record(parsed).theme).themeElements).clrScheme - return THEME_ORDER.map((name) => { - const color = record(record(scheme)[name]) - const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] - return typeof value === 'string' ? value : undefined - }) -} - -function record(value: unknown): Record { - return typeof value === 'object' && value !== null ? value as Record : {} + if (format === 'xlsx') return convertXlsx(bytes, limits) + if (format === 'xls') return convertXls(bytes, limits) + return convertDelimited(bytes, format, limits) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts new file mode 100644 index 0000000000..d66f106cf3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts @@ -0,0 +1,43 @@ +/** CSV and TSV parsing preserves every field as literal text. */ +import Papa from 'papaparse' +import type { Sheet } from '@fortune-sheet/core' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +/** + * Parse delimited UTF-8 or BOM-marked UTF-16 without type inference. + * @param bytes - Borrowed complete file bytes. + * @param format - Comma or tab-separated input. + * @param limits - Maximum worksheet area. + * @returns A single sheet with literal field values. + */ +export function convertDelimited(bytes: Uint8Array, format: 'csv' | 'tsv', limits: ExcelLimits): ExcelPreview { + const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' + : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8' + let text: string + try { text = new TextDecoder(encoding, { fatal: true }).decode(bytes) } + catch (error) { throw new ExcelPreviewError('encoding', { cause: error }) } + const celldata: NonNullable = [] + let row = 0 + let column = 1 + let failure: ExcelPreviewError | undefined + Papa.parse(text, { + delimiter: format === 'csv' ? ',' : '\t', header: false, dynamicTyping: false, skipEmptyLines: false, + step(result, parser) { + row += 1 + column = Math.max(column, result.data.length) + if (result.errors.length > 0 || row * column > limits.maxCells) { + failure = new ExcelPreviewError(result.errors.length > 0 ? 'invalid' : 'tooLarge') + parser.abort() + return + } + result.data.forEach((value, c) => { celldata.push({ r: row - 1, c, v: formatCell({ v: value }, '@') }) }) + }, + }) + if (failure !== undefined) throw failure + return { + sheets: [{ id: '1', name: format.toUpperCase(), order: 0, status: 1, hide: 0, + row: Math.max(1, row), column, celldata, config: {}, luckysheet_select_save: initialSelection({}) }], + missingResults: 0, + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts new file mode 100644 index 0000000000..e3e6affd62 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts @@ -0,0 +1,8 @@ +/** Locale-independent spreadsheet parser failure categories. */ +export class ExcelPreviewError extends Error { + /** @param code - User-actionable parser failure. */ + constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout' | 'encoding', options?: ErrorOptions) { + super(code, options) + this.name = 'ExcelPreviewError' + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index f0be53ffb8..2aaa7ad200 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -3,12 +3,13 @@ import { useEffect, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' -import type { ExcelPreview } from './convert.ts' -import type { ExcelBodyProps } from './LazyExcelBody.tsx' +import type { ExcelFormat } from './format.ts' +import type { ExcelPreview } from './model.ts' +import type { LoadedExcelBodyProps } from './LazyExcelBody.tsx' import { parseExcel } from './parse.ts' import css from './ExcelBody.module.css' -type State = { data: Uint8Array } & ({ value: ExcelPreview } | { error: string }) +type State = { data: Uint8Array; format: ExcelFormat } & ({ value: ExcelPreview } | { error: string }) const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` /** @@ -16,31 +17,32 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` * @param props - Complete workbook bytes, limits, and locale. * @returns An isolated spreadsheet surface with cancellable loading. */ -export function ExcelBody({ content, limits, t }: ExcelBodyProps): ReactNode { +export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined + const delimited = format === 'csv' || format === 'tsv' const [state, setState] = useState() const [attempt, setAttempt] = useState(0) useEffect(() => { if (data === undefined) return const controller = new AbortController() setState(undefined) - void parseExcel(data, limits, controller.signal).then( - (value) => { if (!controller.signal.aborted) setState({ data, value }) }, - (error: unknown) => { if (!controller.signal.aborted) setState({ data, error: error instanceof Error ? error.message : 'invalid' }) }, + void parseExcel(data, format, limits, controller.signal).then( + (value) => { if (!controller.signal.aborted) setState({ data, format, value }) }, + (error: unknown) => { if (!controller.signal.aborted) setState({ data, format, error: error instanceof Error ? error.message : 'invalid' }) }, ) return () => { controller.abort() } - }, [data, limits, attempt]) + }, [data, format, limits, attempt]) if (data === undefined) return

{t('invalid')}

- if (state?.data !== data) return + if (state?.data !== data || state.format !== format) return if ('error' in state) return
- {t(state.error === 'tooLarge' ? 'tooLarge' : state.error === 'timeout' ? 'timeout' : 'invalid')} + {t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}
return
-

{t('summary')}

+

{t(delimited ? 'textSummary' : 'summary')}

{state.value.missingResults > 0 &&

{t('missingResults')}

}
ctx.locale.register('sidebarExcel', { zh, en })) const t = ctx.locale.bind('sidebarExcel') ctx.effect(() => ctx.documentPreviews.register({ - id, extensions: ['xlsx', 'xls'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', + id, extensions: ['xlsx', 'xls', 'csv', 'tsv'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', title: () => t('title'), loading: 'bytes-complete', wrap: false, })) ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index b25bd503a2..cdcde81ed3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -1,10 +1,12 @@ /** Locale-owned Excel preview controls and parser feedback. */ export const zh = { - title: 'Excel', language: 'zh', loading: '正在打开表格…', - invalid: '无法打开此 Excel 文件。文件可能已损坏或受密码保护。', + title: '表格', language: 'zh', loading: '正在打开表格…', + invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', - legacy: '请将此旧版 Excel 文件另存为 .xlsx 后预览。', + encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', summary: '只读预览 · 显示已保存的公式结果', + textSummary: '只读预览 · 按原始文本显示单元格', + xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。', limitations: '图表、图片、数据透视表和条件格式暂不显示。', missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', retry: '重试', @@ -15,11 +17,13 @@ export type ExcelPreviewKey = keyof typeof zh /** English Excel preview copy. */ export const en = { - title: 'Excel', language: 'en', loading: 'Opening spreadsheet…', - invalid: 'This Excel file could not be opened. It may be damaged or password protected.', + title: 'Spreadsheet', language: 'en', loading: 'Opening spreadsheet…', + invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', - legacy: 'Save this legacy Excel file as .xlsx to preview it.', + encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', summary: 'Read-only preview · Saved formula results', + textSummary: 'Read-only preview · Literal cell text', + xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.', limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', retry: 'Retry', diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts new file mode 100644 index 0000000000..74c8d4637e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts @@ -0,0 +1,42 @@ +/** Shared display cells, resource limits, and parser failure categories. */ +import { update as formatNumber, type Cell, type Sheet, type SheetConfig } from '@fortune-sheet/core' + +/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ +export interface ExcelLimits { + /** Maximum source file bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum time allowed for one parser Worker. */ + timeoutMs: number +} + +/** Preview data and a count of formulas without saved results. */ +export interface ExcelPreview { + sheets: Sheet[] + missingResults: number +} + +/** + * Retain an addressable A1 selection, including an A1 merge. + * @param config - Worksheet layout. + * @returns The initial selection used when activating a worksheet. + */ +export function initialSelection(config: SheetConfig): NonNullable { + const merge = config.merge?.['0_0'] + return [{ row: [0, (merge?.rs ?? 1) - 1], column: [0, (merge?.cs ?? 1) - 1], row_focus: 0, column_focus: 0 }] +} + +/** + * Apply number formatting and default alignment without evaluating formulas. + * @param cell - Owned display cell to complete. + * @param numberFormat - Excel number format code. + * @returns The same cell with its display text and value type. + */ +export function formatCell(cell: Cell, numberFormat: string): Cell { + if (cell.ht === undefined) cell.ht = typeof cell.v === 'number' ? 2 : typeof cell.v === 'boolean' ? 0 : 1 + if (cell.ct === undefined) cell.ct = { fa: numberFormat, t: typeof cell.v === 'number' ? 'n' : typeof cell.v === 'boolean' ? 'b' : 's' } + const formatted = typeof cell.v === 'number' ? formatNumber(numberFormat, cell.v) as string | number : cell.v + cell.m = formatted === undefined ? '' : String(formatted) + return cell +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts index 3c7eb83bb3..e173d6a8d3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts @@ -1,15 +1,19 @@ -/** Browser lifetime for the isolated XLSX parser. */ +/** Browser lifetime for the isolated spreadsheet parser. */ import workerSource from './worker.ts?raw' -import type { ExcelLimits, ExcelPreview } from './convert.ts' +import type { ExcelFormat } from './format.ts' +import type { ExcelLimits, ExcelPreview } from './model.ts' /** * Parse workbook bytes in a disposable Worker, copying the retained preview buffer. * @param bytes - Borrowed complete file bytes. + * @param format - Format selected by the registered filename suffix. * @param limits - File, matrix, and elapsed-time limits. * @param signal - Document body's lifetime. * @returns Parsed sheets; rejects with a locale key on parser failures. */ -export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal: AbortSignal): Promise { +export function parseExcel( + bytes: Uint8Array, format: ExcelFormat, limits: ExcelLimits, signal: AbortSignal, +): Promise { signal.throwIfAborted() if (bytes.byteLength > limits.maxBytes) return Promise.reject(new Error('tooLarge')) return new Promise((resolve, reject) => { @@ -42,7 +46,7 @@ export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, if (message.ok === true && 'value' in message && validPreview(message.value)) { finish() resolve(message.value) - } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout'].includes(String(message.code))) { + } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout', 'encoding'].includes(String(message.code))) { finish() reject(new Error(String(message.code))) } else fail() @@ -50,7 +54,7 @@ export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal.addEventListener('abort', abort, { once: true }) try { const copy = bytes.slice() - worker.postMessage({ bytes: copy, limits }, [copy.buffer]) + worker.postMessage({ bytes: copy, format, limits }, [copy.buffer]) } catch (error) { finish(); reject(new Error('invalid', { cause: error })) } }) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts index 408fa82b10..0fbea7a353 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts @@ -1,8 +1,11 @@ /** One workbook per disposable parser Worker; formulas and external links are never executed. */ -import { convertExcel, ExcelPreviewError, type ExcelLimits } from './convert.ts' +import { convertExcel } from './convert.ts' +import type { ExcelFormat } from './format.ts' +import { ExcelPreviewError } from './error.ts' +import type { ExcelLimits } from './model.ts' -globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; limits: ExcelLimits }>) => { - void convertExcel(event.data.bytes, event.data.limits).then( +globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; format: ExcelFormat; limits: ExcelLimits }>) => { + void convertExcel(event.data.bytes, event.data.format, event.data.limits).then( (value) => { globalThis.postMessage({ ok: true, value }) }, (error: unknown) => { globalThis.postMessage({ ok: false, code: error instanceof ExcelPreviewError ? error.code : 'invalid' }) }, ) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts new file mode 100644 index 0000000000..437dc53b0c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts @@ -0,0 +1,98 @@ +/** Legacy BIFF workbooks mapped to saved values and basic spreadsheet layout. */ +import { read, set_cptable, utils, SSF, type CellObject, type WorkBook } from 'xlsx' +import * as codepages from 'xlsx/dist/cpexcel.full.mjs' +import type { Cell, Sheet, SheetConfig } from '@fortune-sheet/core' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +set_cptable(codepages) +// SheetJS publishes SSF as `any`; this is the one formatter capability used by the adapter. +const isDateFormat: (format: string) => boolean = SSF.is_date + +/** + * Decode a binary XLS workbook without accepting renamed text or HTML files. + * @param bytes - Borrowed complete workbook bytes. + * @param limits - Maximum combined worksheet area. + * @returns Saved values, formulas, and supported workbook layout. + */ +export function convertXls(bytes: Uint8Array, limits: ExcelLimits): ExcelPreview { + const compound = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].every((value, index) => bytes[index] === value) + const biffVersion = bytes.at(1) + const biff = bytes[0] === 0x09 && biffVersion !== undefined && [0x00, 0x02, 0x04, 0x08].includes(biffVersion) + if (!compound && !biff) throw new ExcelPreviewError('invalid') + let workbook: ReturnType + try { workbook = read(bytes, { type: 'array', cellNF: true, cellStyles: true, cellFormula: true, sheetStubs: true }) } + catch (error) { throw new ExcelPreviewError('invalid', { cause: error }) } + return mapXlsWorkbook(workbook, limits) +} + +/** + * Map SheetJS values and optional BIFF layout metadata without retaining the workbook. + * @param workbook - Parsed legacy workbook. + * @param limits - Maximum combined worksheet area. + * @returns FortuneSheet display data. + */ +export function mapXlsWorkbook(workbook: WorkBook, limits: ExcelLimits): ExcelPreview { + let area = 0 + let missingResults = 0 + const visibleSheets = workbook.SheetNames.map((_name, order) => !workbook.Workbook?.Sheets?.[order]?.Hidden) + const activeOrder = visibleSheets.indexOf(true) + if (activeOrder === -1) throw new ExcelPreviewError('invalid') + const sheets = workbook.SheetNames.map((name, order): Sheet => { + const source = workbook.Sheets[name] + if (source === undefined) throw new ExcelPreviewError('invalid') + const range = utils.decode_range(source['!ref'] ?? 'A1') + for (const merge of source['!merges'] ?? []) { + range.e.r = Math.max(range.e.r, merge.e.r) + range.e.c = Math.max(range.e.c, merge.e.c) + } + const row = Math.max(range.e.r + 1, source['!rows']?.length ?? 1) + const column = Math.max(range.e.c + 1, source['!cols']?.length ?? 1) + area += row * column + if (!Number.isSafeInteger(area) || area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, + } + source['!rows']?.forEach((value, index) => { + const height = value.hpx ?? (value.hpt === undefined ? undefined : value.hpt * 96 / 72) + if (height !== undefined) config.rowlen[index] = height + if (value.hidden) config.rowhidden[index] = 0 + }) + source['!cols']?.forEach((value, index) => { + if (value.wpx !== undefined) config.columnlen[index] = value.wpx + if (value.hidden) config.colhidden[index] = 0 + }) + const cells = new Map() + for (const address of Object.keys(source)) { + if (address.startsWith('!')) continue + const cell = source[address] as CellObject + const v: Cell = {} + if (cell.f !== undefined) v.f = `=${cell.f}` + if (cell.t === 'e') v.v = utils.format_cell(cell) + else if (cell.v instanceof Date) v.v = cell.v.getTime() / 86_400_000 + 25569 + else if (cell.t !== 'z' && cell.v !== undefined) { + v.v = cell.v + if (typeof v.v === 'number' && workbook.Workbook?.WBProps?.date1904 && isDateFormat(String(cell.z ?? 'General'))) v.v += 1462 + } + if (v.f !== undefined && v.v === undefined) missingResults += 1 + const { r, c } = utils.decode_cell(address) + cells.set(`${r}_${c}`, { r, c, v: formatCell(v, String(cell.z ?? 'General')) }) + } + for (const { s, e } of source['!merges'] ?? []) { + const merge = { r: s.r, c: s.c, rs: e.r - s.r + 1, cs: e.c - s.c + 1 } + config.merge[`${s.r}_${s.c}`] = merge + for (let r = s.r; r <= e.r; r += 1) for (let c = s.c; c <= e.c; c += 1) { + const key = `${r}_${c}` + const cell = cells.get(key) ?? { r, c, v: {} } + if (r === s.r && c === s.c) cell.v.mc = merge + else cell.v = { mc: { r: s.r, c: s.c } } + cells.set(key, cell) + } + } + const visible = visibleSheets[order] === true + const status = order === activeOrder ? 1 : 0 + return { id: String(order + 1), name, order, status, hide: visible ? 0 : 1, row, column, + config, celldata: [...cells.values()], luckysheet_select_save: initialSelection(config) } + }) + return { sheets, missingResults } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts new file mode 100644 index 0000000000..2a5874939b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts @@ -0,0 +1,182 @@ +/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ +import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' +import { type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' +import { XMLParser } from 'fast-xml-parser' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +const BORDER_STYLES: Record = { + thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, + double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, + mediumDashDotDot: 11, slantDashDot: 12, thick: 13, +} +const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] + +/** + * Decode an XLSX into display cells, retaining formulas without recalculation. + * @param bytes - Complete borrowed workbook bytes. + * @param limits - File and matrix allocation limits. + * @returns Sheets ready for a read-only FortuneSheet workbook. + */ +export async function convertXlsx(bytes: Uint8Array, limits: ExcelLimits): Promise { + const workbook = new ExcelJS.Workbook() + try { + await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + } catch (error) { + throw new ExcelPreviewError('invalid', { cause: error }) + } + if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') + let area = 0 + for (const sheet of workbook.worksheets) { + area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) + if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + } + const colors = themeColors(workbook) + let missingResults = 0 + let active = false + const sheets = workbook.worksheets.map((worksheet, order): Sheet => { + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], + } + const celldata: NonNullable = [] + for (const range of worksheet.model.merges) { + // ExcelJS normalizes merged ranges to two cell addresses. + const [start, end] = range.split(':') as [string, string] + const first = worksheet.getCell(start) + const last = worksheet.getCell(end) + const merge = { + r: Number(first.row) - 1, c: Number(first.col) - 1, + rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, + } + config.merge[`${merge.r}_${merge.c}`] = merge + } + // ExcelJS's declarations omit absent parsed collections and style fields. + const columns = worksheet.columns as ExcelJS.Column[] | null + ;(columns ?? []).forEach((column, index) => { + if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) + if (column.hidden) config.colhidden[index] = 0 + }) + worksheet.eachRow({ includeEmpty: true }, (row) => { + const r = row.number - 1 + const height = row.height as number | undefined + if (height !== undefined) config.rowlen[r] = height * 96 / 72 + if (row.hidden) config.rowhidden[r] = 0 + row.eachCell({ includeEmpty: true }, (cell) => { + const c = Number(cell.col) - 1 + const v = convertCell(cell, colors) + if (v.f !== undefined && v.v === undefined) missingResults += 1 + if (cell.isMerged) { + v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } + if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } + } + celldata.push({ r, c, v }) + const border = cell.border as Partial | undefined + if (border !== undefined) { + const value: Record = { row_index: r, col_index: c } + for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { + const edge = border[side] + if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } + } + config.borderInfo.push({ rangeType: 'cell', value }) + } + }) + }) + const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] + const visible = worksheet.state === 'visible' + const status = visible && !active ? 1 : 0 + active ||= visible + const sheet: Sheet = { + id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, + row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), + config, celldata, showGridLines: view?.showGridLines !== false, + defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + luckysheet_select_save: initialSelection(config), + } + if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) + if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { + sheet.frozen = { + type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', + range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, + } + } + return sheet + }) + return { sheets, missingResults } +} + +function convertCell( + cell: Omit & Partial>, + colors: readonly (string | undefined)[], +): Cell { + const font = cell.font + const alignment = cell.alignment + const result: Cell = fontStyle(font, colors) + const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined + if (bg !== undefined) result.bg = bg + if (alignment !== undefined) { + if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 + result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 + result.tb = alignment.wrapText ? '2' : '1' + if (alignment.textRotation === 'vertical') result.tr = '3' + else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation + } + const value = cell.value + let display: ExcelJS.CellValue = value + if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + result.f = `=${cell.formula}` + display = value.result ?? null + } + if (display instanceof Date) { + // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. + result.v = display.getTime() / 86_400_000 + 25569 + } else if (display !== null && typeof display === 'object') { + if ('richText' in display) { + result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } + result.v = display.richText.map(run => run.text).join('') + } else if ('text' in display) result.v = display.text + else result.v = (display as ExcelJS.CellErrorValue).error + } else if (display !== null && display !== undefined) result.v = display + return formatCell(result, cell.numFmt || 'General') +} + +function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { + if (font === undefined) return {} + const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } + if (font.name !== undefined) result.ff = font.name + if (font.size !== undefined) result.fs = font.size + const fc = colorOf(font.color, colors) + if (fc !== undefined) result.fc = fc + return result +} + +function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } + +function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { + if (color === undefined) return undefined + const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) + if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined + // ExcelJS retains OOXML tint even though its Color declaration omits it. + const tint: unknown = Reflect.get(color, 'tint') + if (typeof tint !== 'number' || tint === 0) return `#${rgb}` + return `#${[0, 2, 4].map((offset) => { + const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) + return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') + }).join('')}` +} + +function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { + const themes: unknown = workbook.model.themes + const xml = record(themes).theme1 + if (typeof xml !== 'string') return [] + const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) + const scheme = record(record(record(parsed).theme).themeElements).clrScheme + return THEME_ORDER.map((name) => { + const color = record(record(scheme)[name]) + const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] + return typeof value === 'string' ? value : undefined + }) +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? value as Record : {} +} diff --git a/packages/client/ui-sidebar-documentpreview/src/config.ts b/packages/client/ui-sidebar-documentpreview/src/config.ts index 8949cf2e9f..67fab69d44 100644 --- a/packages/client/ui-sidebar-documentpreview/src/config.ts +++ b/packages/client/ui-sidebar-documentpreview/src/config.ts @@ -14,9 +14,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx index ebdb006f5a..fa3bf9aaf2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { Config } from '../src/config.ts' import { en } from '../src/client/excel/locales.ts' -import type { ExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' +import type { ExcelBodyProps, LoadedExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' const mocked = vi.hoisted(() => ({ parse: vi.fn(), workbook: vi.fn((_props: unknown) => null) })) vi.mock('../src/client/excel/parse.ts', () => ({ parseExcel: mocked.parse })) @@ -14,17 +14,18 @@ import { ExcelBody } from '../src/client/excel/excel.tsx' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps +const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows loading then a workbook with editing and recalculation disabled', async () => { mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) - const view = render() + const view = render() expect(screen.getByRole('status', { name: en.loading })).toBeDefined() await screen.findByText(en.summary) expect(screen.getByText(en.missingResults)).toBeDefined() expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) - const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.unmount() expect(signal.aborted).toBe(true) }) @@ -32,48 +33,47 @@ it('shows loading then a workbook with editing and recalculation disabled', asyn it('ignores a retired file result and cancels parsing when the bytes change', async () => { let finish!: (value: unknown) => void mocked.parse.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })).mockResolvedValueOnce(value) - const view = render() - const signal = mocked.parse.mock.calls[0]![2] as AbortSignal - view.rerender() + const view = render() + const signal = mocked.parse.mock.calls[0]![3] as AbortSignal + view.rerender() await screen.findByText(en.summary) expect(signal.aborted).toBe(true) finish({ ...value, missingResults: 1 }) await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) }) -it.each(['invalid', 'tooLarge', 'timeout', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { +it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { mocked.parse.mockRejectedValueOnce(new Error(error)).mockResolvedValueOnce(value) - render() - const key = error === 'tooLarge' || error === 'timeout' ? error : 'invalid' + render() + const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid' await screen.findByText(en[key]) fireEvent.click(screen.getByRole('button', { name: en.retry })) await screen.findByText(en.summary) expect(mocked.parse).toHaveBeenCalledTimes(2) }) -it('rejects non-byte content and shows legacy XLS guidance without loading a parser', () => { - const view = render() +it('rejects non-byte content without allocating a parser', () => { + render() expect(screen.getByRole('alert').textContent).toBe(en.invalid) - view.rerender() - expect(screen.getByRole('alert').textContent).toBe(en.legacy) expect(mocked.parse).not.toHaveBeenCalled() }) -it('loads the spreadsheet chunk for an XLSX file', async () => { +it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => { mocked.parse.mockResolvedValue(value) - render() - await screen.findByText(en.summary) + render() + await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary) + expect(mocked.parse.mock.calls[0]![1]).toBe(format) }) it('uses generic guidance for non-Error failures and ignores rejection after unmount', async () => { mocked.parse.mockRejectedValueOnce('parser failure') - const view = render() + const view = render() await screen.findByText(en.invalid) view.unmount() let reject!: (error: Error) => void mocked.parse.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) - const pending = render() - const signal = mocked.parse.mock.calls[1]![2] as AbortSignal + const pending = render() + const signal = mocked.parse.mock.calls[1]![3] as AbortSignal pending.unmount() reject(new Error('closed')) await waitFor(() => { expect(signal.aborted).toBe(true) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 2008eafe53..31a1b35c58 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -11,7 +11,7 @@ const limits = Config({}).excel describe('Excel conversion', () => { it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { const input = await excelFixture() - const { sheets, missingResults } = await convertExcel(input, limits) + const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits) expect(input.byteLength).toBeGreaterThan(0) expect(sheets.map(sheet => sheet.name)).toEqual(['季度预算', '公式与格式', '隐藏页']) const first = sheets[0]! @@ -28,14 +28,14 @@ describe('Excel conversion', () => { const second = sheets[1]! expect(second.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }]) expect(second.celldata).toEqual(expect.arrayContaining([ - expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), - expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), - expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) as unknown }), - expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) as unknown } }) as unknown }), - expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) as unknown }), - expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) as unknown }), - expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) as unknown }), - expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) as unknown }), + expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) }), + expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) }), + expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) }), + expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) } }) }), + expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) }), + expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) }), + expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) }), + expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) }), ])) expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } }) expect(sheets[2]).toMatchObject({ hide: 1, status: 0 }) @@ -43,21 +43,21 @@ describe('Excel conversion', () => { }) it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { - await expect(convertExcel(new Uint8Array([1, 2, 3]), limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) const input = await excelFixture() - await expect(convertExcel(input, { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) + await expect(convertExcel(input, 'xlsx', { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Sparse').getCell('Z1000').value = 1 - await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) }) it('opens an empty worksheet and rejects workbooks with no visible sheet', async () => { const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Empty') - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]).toMatchObject({ name: 'Empty', row: 1, column: 1, status: 1 }) workbook.getWorksheet('Empty')!.state = 'veryHidden' - await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) }) it.each([false, true])('retains dates for the 1904 epoch flag %s', async (date1904) => { @@ -66,7 +66,7 @@ describe('Excel conversion', () => { const cell = workbook.addWorksheet('Dates').getCell('A1') cell.value = new Date('2024-02-29T12:00:00Z') cell.numFmt = 'yyyy-mm-dd hh:mm' - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]!.celldata![0]!.v).toMatchObject({ m: '2024-02-29 12:00' }) }) @@ -92,7 +92,7 @@ describe('Excel conversion', () => { sheet.getCell('E1').font = { color: { argb: 'FFFF0000', tint: 0 } as Partial } sheet.getCell('F1').value = 42 sheet.getCell('F1').alignment = { vertical: 'middle' } - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]).toMatchObject({ defaultColWidth: 89, config: { columnlen: { 0: 6 } } }) const cells = result.sheets[0]!.celldata! expect(cells[0]!.v).toMatchObject({ fc: '#a7c0de', bg: '#28415f', cl: 1, un: 1, ht: 2, vt: 1, tb: '2', tr: '3' }) @@ -106,7 +106,7 @@ describe('Excel conversion', () => { it.each([{ ySplit: 2 }, { xSplit: 1 }, {}])('maps one-axis or empty freeze settings %j', async (freeze) => { const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Frozen', { views: [{ state: 'frozen', ...freeze, showGridLines: false }] }).getCell('A1').value = 1 - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]!.showGridLines).toBe(false) expect(result.sheets[0]!.frozen?.type).toBe('ySplit' in freeze ? 'rangeRow' : 'xSplit' in freeze ? 'rangeColumn' : undefined) }) @@ -114,8 +114,8 @@ describe('Excel conversion', () => { it('accepts workbooks without a theme and ignores absent theme colors', async () => { const files = unzipSync(await excelFixture()) delete files['xl/theme/theme1.xml'] - expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + expect((await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).sheets).toHaveLength(3) files['xl/theme/theme1.xml'] = strToU8('') - expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + expect((await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).sheets).toHaveLength(3) }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts new file mode 100644 index 0000000000..6ca70fd926 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts @@ -0,0 +1,140 @@ +/** Additional spreadsheet formats retain layout and literal delimited fields. */ +import { readFileSync } from 'node:fs' +import { utils, write } from 'xlsx' +import { expect, it } from 'vitest' +import { Config } from '../src/config.ts' +import { convertExcel } from '../src/client/excel/convert.ts' +import { excelFormat } from '../src/client/excel/format.ts' +import { mapXlsWorkbook } from '../src/client/excel/xls.ts' +import { legacyWorkbook, xlsFixture } from './xls-fixture.ts' + +const limits = Config({}).excel +const encode = (text: string) => new TextEncoder().encode(text) + +it.each(['xlsx', 'xls', 'csv', 'tsv'] as const)('resolves mixed-case %s paths and enforces byte limits', async (format) => { + expect(excelFormat(`/dir.name/sheet.${format.toUpperCase()}`)).toBe(format) + await expect(convertExcel(new Uint8Array(2), format, { ...limits, maxBytes: 1 })).rejects.toMatchObject({ code: 'tooLarge' }) +}) + +it('rejects unsupported filename suffixes', () => { + expect(() => excelFormat('file.xlsm')).toThrow('invalid') +}) + +it.each([false, true])('reads BIFF values, formulas, merges, layout, and dates with 1904 epoch %s', async (date1904) => { + const { sheets, missingResults } = await convertExcel(xlsFixture(legacyWorkbook(date1904)), 'xls', limits) + expect(sheets.map(sheet => [sheet.name, sheet.hide, sheet.status])).toEqual([['预算', 0, 1], ['明细', 0, 0], ['隐藏', 1, 0]]) + const first = sheets[0]! + const cell = (r: number, c: number) => first.celldata!.find(cell => cell.r === r && cell.c === c)!.v! + expect(cell(0, 0)).toMatchObject({ v: '旧版预算', mc: { r: 0, c: 0, rs: 1, cs: 3 } }) + expect(cell(0, 1)).toEqual({ mc: { r: 0, c: 0 } }) + expect(cell(2, 1)).toMatchObject({ v: 1234.5, m: '1,234.50' }) + expect(cell(2, 2)).toMatchObject({ v: 0.25, m: '25.0%' }) + expect(cell(3, 1)).toMatchObject({ f: '=1+2', v: 3, m: '3' }) + expect(cell(4, 1)).toMatchObject({ m: '2024-03-01' }) + expect(cell(4, 2)).toMatchObject({ v: true }) + expect(first.config).toMatchObject({ colhidden: { 2: 0 } }) + expect(first.config!.columnlen![0]).toBeGreaterThan(100) + expect(first.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 2], row_focus: 0, column_focus: 0 }]) + expect(missingResults).toBe(0) +}) + +it('reads an independently generated Office XLS fixture', async () => { + const bytes = readFileSync(new URL('../../../../apps/web/tests/fixtures/office/preview.xls', import.meta.url)) + const result = await convertExcel(new Uint8Array(bytes), 'xls', limits) + expect(result.sheets.flatMap(sheet => sheet.celldata).some(cell => String(cell?.v?.v).includes('Office preview'))).toBe(true) +}) + +it('maps optional row metadata, empty formula caches, errors, and Date values', () => { + // SheetJS reads row metadata but its BIFF writer omits ROW records. + const workbook = legacyWorkbook() + const sheet = workbook.Sheets['预算']! + sheet['!rows'] = [{ hpt: 30 }, { hpx: 18 }, { hidden: true }, {}] + sheet['!cols'] = [{ wpx: 130 }, { hidden: true }, {}] + sheet['B4'] = { t: 'n', f: '1+2' } + sheet['B5'] = { t: 'd', v: new Date('2024-03-01T00:00:00Z'), z: 'yyyy-mm-dd' } + sheet['C5'] = { t: 'e', v: 7, w: '#DIV/0!' } + const { sheets, missingResults } = mapXlsWorkbook(workbook, limits) + expect(sheets[0]!.config).toMatchObject({ rowlen: { 0: 40, 1: 18 }, rowhidden: { 2: 0 }, columnlen: { 0: 130 }, colhidden: { 1: 0 } }) + expect(sheets[0]!.celldata).toEqual(expect.arrayContaining([ + { r: 3, c: 1, v: expect.objectContaining({ f: '=1+2', m: '' }) }, + { r: 4, c: 1, v: expect.objectContaining({ m: '2024-03-01' }) }, + { r: 4, c: 2, v: expect.objectContaining({ v: '#DIV/0!' }) }, + ])) + expect(missingResults).toBe(1) +}) + +it('rejects damaged, renamed, hidden-only, and oversized legacy workbooks', async () => { + for (const bytes of [encode('a,b\n1,2'), encode('not a workbook'), new Uint8Array([9, 8, 0]), new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])]) { + await expect(convertExcel(bytes, 'xls', limits)).rejects.toMatchObject({ code: 'invalid' }) + } + const workbook = legacyWorkbook() + const metadata = workbook.Workbook?.Sheets + if (metadata === undefined) throw new Error('legacy fixture has no workbook sheet metadata') + metadata.forEach((sheet) => { sheet.Hidden = 1 }) + await expect(convertExcel(xlsFixture(workbook), 'xls', limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(xlsFixture(), 'xls', { ...limits, maxCells: 10 })).rejects.toMatchObject({ code: 'tooLarge' }) + const sparse = utils.book_new() + utils.book_append_sheet(sparse, { '!ref': 'A1:Z1000', Z1000: { t: 'n', v: 1 } }, 'Sparse') + await expect(convertExcel(xlsFixture(sparse), 'xls', { ...limits, maxCells: 25_999 })).rejects.toMatchObject({ code: 'tooLarge' }) + const missing = utils.book_new() + missing.SheetNames.push('Missing') + expect(() => mapXlsWorkbook(missing, limits)).toThrow('invalid') +}) + +it('opens raw BIFF and empty legacy worksheets', async () => { + const workbook = utils.book_new() + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['BIFF']]), 'Sheet') + const biff = new Uint8Array(write(workbook, { type: 'array', bookType: 'biff2' }) as ArrayBuffer) + expect((await convertExcel(biff, 'xls', limits)).sheets[0]!.celldata![0]!.v!.v).toBe('BIFF') + workbook.Sheets['Sheet'] = {} + expect((await convertExcel(xlsFixture(workbook), 'xls', limits)).sheets[0]).toMatchObject({ row: 1, column: 1, status: 1 }) + const sheet = workbook.Sheets['Sheet'] + if (sheet === undefined) throw new Error('legacy fixture has no Sheet worksheet') + delete sheet['!ref'] + expect(mapXlsWorkbook(workbook, limits).sheets[0]).toMatchObject({ row: 1, column: 1 }) + workbook.Sheets['Sheet'] = { '!ref': 'A1', A1: { t: 'n', v: 12 } } + workbook.Workbook = { WBProps: { date1904: true } } + expect(mapXlsWorkbook(workbook, limits).sheets[0]!.celldata![0]!.v!.v).toBe(12) +}) + +it.each(['csv', 'tsv'] as const)('preserves %s strings, quoted separators, newlines, empty fields, and ragged rows', async (format) => { + const delimiter = format === 'csv' ? ',' : '\t' + const text = ['编号', '说明', '值'].join(delimiter) + '\r\n' + + ['00123', `"中文${delimiter}字段\n第二行 ""引号"""`, '=SUM(1,2)'].map((field, index) => index === 2 ? '"' + field + '"' : field).join(delimiter) + '\r\n' + + ['2024-03-01', '', 'TRUE', '99999999999999999999'].join(delimiter) + '\r\nshort\r\n\r\n' + const result = await convertExcel(encode(text), format, limits) + const sheet = result.sheets[0]! + const cell = (r: number, c: number) => sheet.celldata!.find(cell => cell.r === r && cell.c === c)!.v! + expect(sheet.column).toBe(4) + expect(cell(0, 0).v).toBe('编号') + expect(cell(1, 0)).toMatchObject({ v: '00123', ct: { t: 's', fa: '@' } }) + expect(cell(1, 1).v).toBe(`中文${delimiter}字段\n第二行 "引号"`) + expect(cell(1, 2)).toMatchObject({ v: '=SUM(1,2)', m: '=SUM(1,2)' }) + expect(cell(1, 2)).not.toHaveProperty('f') + expect(cell(2, 0).v).toBe('2024-03-01') + expect(cell(2, 1).v).toBe('') + expect(cell(2, 2).v).toBe('TRUE') + expect(cell(2, 3).v).toBe('99999999999999999999') + expect(cell(3, 0).v).toBe('short') + expect(sheet.row).toBeGreaterThan(4) + expect(result.missingResults).toBe(0) +}) + +it.each(['utf8', 'le', 'be'] as const)('decodes BOM-marked %s text', async (encoding) => { + const text = '\ufeff名称,编号\n中文,001' + const bytes = encoding === 'utf8' ? encode(text) : new Uint8Array(Buffer.from(text, 'utf16le')) + if (encoding === 'be') for (let i = 0; i < bytes.length; i += 2) [bytes[i], bytes[i + 1]] = [bytes[i + 1]!, bytes[i]!] + const sheet = (await convertExcel(bytes, 'csv', limits)).sheets[0]! + expect(sheet.celldata![0]!.v!.v).toBe('名称') + expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 0)!.v!.v).toBe('中文') +}) + +it('handles empty text and rejects invalid encoding, broken quoting, and excess area', async () => { + expect((await convertExcel(encode(''), 'csv', limits)).sheets[0]).toMatchObject({ row: 1, column: 1 }) + for (const bytes of [new Uint8Array([0xff]), new Uint8Array([0xff, 0xfe, 1])]) { + await expect(convertExcel(bytes, 'csv', limits)).rejects.toMatchObject({ code: 'encoding' }) + } + await expect(convertExcel(encode('a,"unclosed'), 'csv', limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(encode('a,b\nc,d'), 'csv', { ...limits, maxCells: 3 })).rejects.toMatchObject({ code: 'tooLarge' }) + expect((await convertExcel(encode('a,b\nc,d'), 'csv', { ...limits, maxCells: 4 })).sheets[0]).toMatchObject({ row: 2, column: 2 }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts index bf57fe5b30..ad0cb07b82 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts @@ -26,7 +26,7 @@ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals( it('transfers a private copy and releases the Worker and URL after parsing', async () => { const input = new Uint8Array([1, 2, 3]) - const result = parseExcel(input, limits, new AbortController().signal) + const result = parseExcel(input, 'xlsx', limits, new AbortController().signal) const [message, transfer] = instance.postMessage.mock.calls[0]! expect(message.bytes).toEqual(input) expect(message.bytes).not.toBe(input) @@ -41,14 +41,14 @@ it('transfers a private copy and releases the Worker and URL after parsing', asy it('does not allocate a Worker for aborted or oversized input', async () => { const worker = vi.fn() vi.stubGlobal('Worker', worker) - expect(() => parseExcel(new Uint8Array(), limits, AbortSignal.abort())).toThrow() - await expect(parseExcel(new Uint8Array(2), { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') + expect(() => parseExcel(new Uint8Array(), 'xlsx', limits, AbortSignal.abort())).toThrow() + await expect(parseExcel(new Uint8Array(2), 'xlsx', { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') expect(worker).not.toHaveBeenCalled() }) it('cancels an active parser without retaining callbacks or timers', async () => { const controller = new AbortController() - const result = parseExcel(new Uint8Array(), limits, controller.signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, controller.signal) const rejection = expect(result).rejects.toThrow('closed') controller.abort(new Error('closed')) await rejection @@ -58,7 +58,7 @@ it('cancels an active parser without retaining callbacks or timers', async () => }) it('terminates a parser that exceeds its time budget', async () => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) const rejection = expect(result).rejects.toThrow('timeout') await vi.advanceTimersByTimeAsync(limits.timeoutMs) await rejection @@ -68,14 +68,14 @@ it('terminates a parser that exceeds its time budget', async () => { it.each([null, 7, {}, { ok: true, value: null }, { ok: true, value: { sheets: [], missingResults: 0 } }, { ok: true, value: { sheets: [{}], missingResults: 0 } }, { ok: true, value: { ...preview, missingResults: -1 } }, { ok: false, code: 'surprise' }, { ok: false, code: 'invalid' }])('rejects invalid Worker message %j', async (data) => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) instance.onmessage!({ data }) await expect(result).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() }) it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s', async (event) => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) instance[event]!() await expect(result).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() @@ -83,7 +83,7 @@ it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s' it('revokes the URL when Worker construction fails', async () => { vi.stubGlobal('Worker', vi.fn(function () { throw new Error('unavailable') })) - await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + await expect(parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal)).rejects.toThrow('invalid') expect(revokeUrl).toHaveBeenCalledOnce() }) @@ -93,7 +93,7 @@ it('terminates the Worker when posting the copied buffer fails', async () => { instance.postMessage.mockImplementation(() => { throw new Error('transfer failed') }) return instance })) - await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + await expect(parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal)).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index ca4161c026..4503dbaa97 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -22,15 +22,14 @@ it('registers complete binary reads and removes the slot and locale on disposal' const fiber = ctx.plugin({ apply: (scope) => { apply(scope, limits) } }) try { await fiber.await() - for (const path of ['budget.XLSX', 'legacy.xls']) { + for (const path of ['budget.XLSX', 'legacy.xls', 'table.CSV', 'table.tsv']) { const candidate = registry.candidates(path)[0]! expect(candidate).toMatchObject({ loading: 'bytes-complete', wrap: false, binaryExtensions: ['xlsx', 'xls'] }) - expect(candidate.title()).toBe('Excel') - expect(candidate.read).toBeUndefined() - expect(candidate.bodyId).toBeUndefined() + expect(candidate.title()).toBe('Spreadsheet') + expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path)) } expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en }) - expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) as unknown }), LazyExcelBody) + expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) }), LazyExcelBody) const options = register.mock.calls[0]![0] as { inject: () => unknown } expect(options.inject()).toEqual({ limits }) } finally { await fiber.dispose() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts index 606e7e30a6..e6e6e743a0 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts @@ -12,7 +12,7 @@ it('returns parsed sheets and bounded failure categories', async () => { await import('../src/client/excel/worker.ts') const handle: (event: MessageEvent) => void = globalThis.onmessage! const message = (bytes: Uint8Array, maxCells = 250_000) => { - handle({ data: { bytes, limits: { ...Config({}).excel, maxCells } } } as MessageEvent) + handle({ data: { bytes, format: 'xlsx', limits: { ...Config({}).excel, maxCells } } } as MessageEvent) } message(await excelFixture()) await vi.waitFor(() => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ ok: true })) }) @@ -32,6 +32,6 @@ it('reports an unexpected parser failure as an invalid workbook', async () => { }) await import('../src/client/excel/worker.ts') const handle: (event: MessageEvent) => void = globalThis.onmessage! - handle({ data: { bytes: new Uint8Array(), limits: Config({}).excel } } as MessageEvent) + handle({ data: { bytes: new Uint8Array(), format: 'xlsx', limits: Config({}).excel } } as MessageEvent) await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts index d583495e9e..d5837ba7a7 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts @@ -42,8 +42,8 @@ function runPnpm(args: string[], cwd: string, timeout: number): string { : run(entrypoint, args, cwd, timeout) } -describe('published PDF.js licenses', () => { - it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed PDF chunk', ({ task }) => { +describe('published document preview licenses', () => { + it.skipIf(!existsSync(bundlePath))('keeps bundled licenses in the packed lazy chunks', ({ task }) => { expect(existsSync(pdfChunkPath)).toBe(true) const output = mkdtempSync(join(tmpdir(), 'dsh-document-preview-pack-')) try { @@ -70,13 +70,18 @@ describe('published PDF.js licenses', () => { expect(excel).toContain('//! Bundled spreadsheet license notices') expect(excel).toContain('Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd') expect(excel).toContain('Permission is hereby granted, free of charge') + for (const dependency of ['xlsx', 'papaparse']) { + const root = dirname(require.resolve(dependency === 'xlsx' ? dependency : `${dependency}/package.json`)) + const license = readFileSync(join(root, 'LICENSE'), 'utf8').trimEnd() + expect(excel).toContain(license.split('\n').map(line => `// ${line}`).join('\n')) + } let initialized = false runInNewContext(excel, { window: { __ModuleLoader__: { load: (registration: { factory: (resolve: (specifier: string) => unknown) => { ExcelBody: unknown } }) => { const loaded = registration.factory((specifier) => { if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return {} - if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) as unknown + if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) throw new Error(`Unexpected browser dependency: ${specifier}`) }) expect(typeof loaded.ExcelBody).toBe('function') diff --git a/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts new file mode 100644 index 0000000000..7da6cea5b0 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts @@ -0,0 +1,41 @@ +/** Binary BIFF fixture with saved formulas, dates, merges, and hidden worksheets. */ +import { utils, write, type CellObject, type WorkBook } from 'xlsx' + +/** + * Build a legacy workbook for parser and browser scenarios. + * @param date1904 - Whether date serials use the 1904 epoch. + * @returns A workbook accepted by the BIFF writer. + */ +export function legacyWorkbook(date1904 = false): WorkBook { + const workbook = utils.book_new() + const sheet = utils.aoa_to_sheet([ + ['旧版预算', null, null], ['项目', '金额', '比例'], ['研发', 1234.5, 0.25], + ['公式结果', 3, null], ['日期', date1904 ? 43890 : 45352, true], + ]) + const amount = sheet['B3'] as CellObject | undefined + const ratio = sheet['C3'] as CellObject | undefined + const date = sheet['B5'] as CellObject | undefined + if (amount === undefined || ratio === undefined || date === undefined) throw new Error('legacy fixture cells are missing') + amount.z = '#,##0.00' + ratio.z = '0.0%' + date.z = 'yyyy-mm-dd' + // MS-XLS CellParsedFormula: seven token bytes encode the expression 1+2. + sheet['B4'] = { t: 'n', v: 3, bf: [7, 0, 0x1e, 1, 0, 0x1e, 2, 0, 0x03] } as CellObject & { bf: number[] } + sheet['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }] + sheet['!cols'] = [{ wch: 20 }, { wch: 16 }, { wch: 12, hidden: true }] + sheet['!rows'] = [{ hpt: 30 }, {}, {}, {}, { hidden: true }] + utils.book_append_sheet(workbook, sheet, '预算') + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['Second sheet']]), '明细') + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['Hidden']]), '隐藏') + workbook.Workbook = { WBProps: { date1904 }, Sheets: [{ Hidden: 0, name: '预算' }, { Hidden: 0, name: '明细' }, { Hidden: 1, name: '隐藏' }] } + return workbook +} + +/** + * Serialize the legacy workbook to a real XLS container. + * @param workbook - Fixture workbook, optionally modified by the scenario. + * @returns Complete BIFF8 compound-file bytes. + */ +export function xlsFixture(workbook = legacyWorkbook()): Uint8Array { + return new Uint8Array(write(workbook, { type: 'array', bookType: 'biff8' }) as ArrayBuffer) +} diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index 950f7bb930..2f11b42361 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -17,7 +17,11 @@ function excelLicenseBanner(): string { const fortune = readFileSync(join(import.meta.dirname, 'licenses/FortuneSheet.txt'), 'utf8') const excel = readFileSync(join(dirname(require.resolve('exceljs/package.json')), 'LICENSE'), 'utf8') const xml = readFileSync(join(dirname(dirname(require.resolve('fast-xml-parser'))), 'LICENSE'), 'utf8') - return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') + const csv = readFileSync(join(dirname(require.resolve('papaparse/package.json')), 'LICENSE'), 'utf8') + const xlsRoot = dirname(require.resolve('xlsx')) + const xls = readdirSync(xlsRoot).filter(name => /^(LICENSE|NOTICE)(\.|$)/u.test(name)).sort() + .map(name => readFileSync(join(xlsRoot, name), 'utf8')).join('\n') + return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}\n${csv}\n${xls}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') } /** License files for PDF.js and the data embedded beside its runtime. */ @@ -68,10 +72,19 @@ const excelWorker: NonNullable = [{ resolveId(source) { return source === './worker.ts?raw' ? '\0dsh-excel-worker-source' : null }, async load(id) { if (id !== '\0dsh-excel-worker-source') return null + const parent = this const worker = await Rolldown.rolldown({ input: join(import.meta.dirname, 'src/client/excel/worker.ts'), platform: 'browser', resolve: { mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, transform: { define: { 'process.env.NODE_ENV': JSON.stringify('production') } }, + plugins: [{ + name: 'dsh-excel-worker-dependencies', + async resolveId(source, importer) { + // The outer build's license analysis must also see imports embedded in Worker text. + if (importer?.startsWith(join(import.meta.dirname, 'src')) && !source.startsWith('.')) await parent.resolve(source, importer) + return null + }, + }], }) try { const result = await worker.generate({ format: 'iife', minify: true }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f8f5e478a..914620af95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4550,6 +4550,9 @@ importers: '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/papaparse': + specifier: ^5.3.16 + version: 5.5.2 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4571,6 +4574,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + papaparse: + specifier: 5.5.3 + version: 5.5.3 pdfjs-dist: specifier: 6.3.289 version: 6.3.289 @@ -4580,6 +4586,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz packages/client/ui-sidebar-files: devDependencies: @@ -16007,6 +16016,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} @@ -18977,6 +18989,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -20323,6 +20338,12 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -23062,6 +23083,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 22.20.0 + '@types/picomatch@3.0.2': {} '@types/picomatch@4.0.3': {} @@ -26501,6 +26526,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parse-ms@4.0.0: {} parse5@8.0.1: @@ -27985,6 +28012,8 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index af06b3f429..0da7b0e270 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -78,8 +78,15 @@ - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent -- Legacy guidance: Save this legacy Excel file as .xlsx to preview it. -- Invalid XLSX: This Excel file could not be opened. It may be damaged or password protected. +- XLS: merged title copied; worksheet selection retained +- Invalid XLS/XLSX: This spreadsheet could not be opened. Check its format, contents, or password protection. + +## 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 ## Unknown suffix From f51092015f1ad2b4f1e0b4c7e0bf0d4ac80992d8 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 12:27:01 +0800 Subject: [PATCH 16/36] refactor(preview): align spreadsheets with shared file preview structure --- .../architecture/2026-09-11-node-office-kit.i18n.yaml | 4 ++-- .../implemented/architecture/2026-09-11-node-office-kit.md | 2 +- .../implemented/architecture/2026-09-11-node-office-kit.zh.md | 2 +- packages/client/ui-sidebar-documentpreview/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-documentpreview/README.md | 2 +- packages/client/ui-sidebar-documentpreview/README.zh.md | 2 +- .../tests/excel-registration.client.spec.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 9f2569bdfb..8e3e128348 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -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: 8a974903f91c75d111a798bd9797120fef547f9f -2026-09-11-node-office-kit.zh.md: f4dd249c8e5d098308bf8f4cce7fcea1202a9efb +2026-09-11-node-office-kit.md: 36897178014daa839399adb5fdf72a4ca53cec8b +2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 8a974903f9..3689717801 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -10,7 +10,7 @@ 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. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index f4dd249c8e..1255012bf6 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -10,7 +10,7 @@ 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。 diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 01384a6bee..61434cb5b5 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: d4392dc889b1e1cb052412900fe719174aabb508 -README.zh.md: b05257bcf3f816b6d1040a6d5489a545d6d25d5d +README.md: 6f27ddccd3ecde10310ae2c2efca2ec2e3bd5a58 +README.zh.md: c3a95db1683e64d72963d6b3854527e0076683a8 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d4392dc889..6f27ddccd3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -52,7 +52,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. -- **Complete bytes** — PDF, HTML, and common images call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Complete bytes** — PDF, HTML, common images, and spreadsheets call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF and spreadsheet renderers copy retained bytes before Worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. - **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index b05257bcf3..c3a95db168 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -52,7 +52,7 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 -- **完整字节** —— PDF、HTML 和常见图片调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 和表格渲染器在传给 Worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 - **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index 4503dbaa97..f00d3341b6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { binaryDocumentPath, DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { Config } from '../src/config.ts' import { apply } from '../src/client/excel/index.ts' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' From 9f6434d24b6456a54ecc04a06b5c9064cde8e01e Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 16:24:00 +0800 Subject: [PATCH 17/36] refactor(web): move Office loading into its injected face --- ...9-08-document-preview-operations.i18n.yaml | 4 +- .../2026-09-08-document-preview-operations.md | 2 +- ...26-09-08-document-preview-operations.zh.md | 2 +- docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 2 +- docs/subsystems/sidebar-right.zh.md | 2 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/office/OfficeBody.tsx | 33 +++------- .../src/client/office/face.ts | 64 +++++++++++++++++++ .../src/client/office/index.ts | 10 ++- .../src/client/office/locales.ts | 4 +- .../tests/office-registration.client.spec.ts | 15 +++-- .../tests/renderer-loading.client.spec.tsx | 63 +++++++++++++++--- .../src/client/slot-catalog.ts | 2 +- scripts/no-unknown-casts.baseline.json | 5 -- 17 files changed, 161 insertions(+), 63 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/face.ts diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index ecb554c14d..6ccdf5d446 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 0b90c17ba9..220db6db04 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index ec9c208c97..7b838711e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -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 回调负责读取。 diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index b4d97350d4..07ae54182f 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -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 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index bade9a958a..7ec6d931ef 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -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 diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 93a6f1f251..413ea2145d 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -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)定义加载生命周期。 ## 资源模型 diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 61434cb5b5..9c012fb5ea 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: 6f27ddccd3ecde10310ae2c2efca2ec2e3bd5a58 -README.zh.md: c3a95db1683e64d72963d6b3854527e0076683a8 +README.md: 8c845b6236c637f340ce7d2be0fdc25f7950dcdc +README.zh.md: 4f1a5f55f35495ba51b55f67a7864396169a2304 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 6f27ddccd3..8c845b6236 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -34,7 +34,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. -`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. +`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected face owns content loading, errors, store updates, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. ## Addresses @@ -102,7 +102,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar
Office implementation — click to expand -Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. +Office registration, loading, caching, and font notices live in `src/client/office/`. The injected Office face writes converted PDF bytes, font metadata, and failures through the declared store actions. The Office body triggers loading, binds cancellation to its lifecycle, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index c3a95db168..4f1a5f55f3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 -`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 +`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入 face 负责内容加载、错误、store 更新和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 ## 地址 @@ -102,7 +102,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
Office 实现——点击展开 -Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 +Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注入的 Office face 通过已声明的 store action 写入转换后的 PDF 字节、字体元数据和失败。Office 正文触发加载,将取消绑定到自身生命周期,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 `documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx index b921f9269f..5915ab3655 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx @@ -1,7 +1,6 @@ -/** Office owns source loading and conversion failures around the shared PDF view. */ +/** Office presents retained conversion results and font notices around the shared PDF view. */ import { useEffect, type ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' -import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { Button, FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' @@ -9,7 +8,7 @@ import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar- import type { DocumentBodyOwner, DocumentPreviewProps } from '../document/contract.ts' import { hostFileOf } from '../rpc.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' -import type { ReadOfficeDocument } from './cache.ts' +import type { OfficeLoadInjected } from './face.ts' import type { OfficeStore } from './store.ts' import common from '../TextPreview.module.css' import css from './OfficeBody.module.css' @@ -28,10 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Office loading callbacks supplied by the registration's services. */ -export interface OfficeBodyInjected { - readonly read: ReadOfficeDocument - /** @param failure - declared file-read failure or conversion exception message. @returns localized display text. */ - readonly describeFailure: (failure: RemoteFailure | { readonly message: string }) => string +export interface OfficeBodyInjected extends OfficeLoadInjected { /** @param tab - owning tab. @param signal - tab lifetime, including hidden bodies. */ readonly retainTab: (tab: TabId, signal: AbortSignal) => void } @@ -47,35 +43,22 @@ export type OfficeBodyProps = DocumentPreviewProps & PropsStore & O */ export function OfficeBody(props: OfficeBodyProps): ReactNode { const { tab } = props.useTabInfo() - const { actions, read, retainTab, describeFailure, resourceAddress, t } = props + const { load, retainTab, resourceAddress, t } = props const request = props.content.kind === 'renderer' ? props.content : undefined const revision = request?.revision const held = props.useStore(state => state.byTab[tab.id]) const view = held?.revision === revision ? held : undefined const settled = view?.file !== undefined || view?.failure !== undefined useEffect(() => { retainTab(tab.id, tab.signal) }, [retainTab, tab.id, tab.signal]) + // Metadata may replace callbacks without advancing the content revision. useEffect(() => { - if (revision === undefined || settled || tab.signal.aborted) return + if (request === undefined || settled || tab.signal.aborted) return const controller = new AbortController() const signal = AbortSignal.any([controller.signal, tab.signal]) - actions.loading(tab.id, revision) - void read(hostFileOf(resourceAddress), signal).then((result) => { - if (signal.aborted) return - if (result.ok) actions.complete(tab.id, revision, result.value) - else actions.failed(tab.id, revision, { code: result.error.code, message: describeFailure(result.error) }) - }, (error: unknown) => { - if (!signal.aborted) actions.failed(tab.id, revision, { - code: 'gateway/internal', message: describeFailure({ message: error instanceof Error ? error.message : String(error) }), - }) - }) + load(tab.id, request.revision, hostFileOf(resourceAddress), signal, request.loaded, request.failed) return () => { controller.abort() } - }, [revision, resourceAddress, tab.id, tab.signal, read, actions, describeFailure, settled]) + }, [revision, resourceAddress, tab.id, tab.signal, load, settled]) const file = view?.file - useEffect(() => { - if (request === undefined) return - if (file !== undefined) request.loaded(file.version) - else if (view?.failure !== undefined) request.failed() - }, [file, view?.failure, request]) if (request === undefined) return null if (view?.failure !== undefined) { const { name } = pathPartsOf(resourceAddress) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts new file mode 100644 index 0000000000..48d953ba67 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts @@ -0,0 +1,64 @@ +/** Office conversion reads and settlements bound to the renderer's declared store. */ +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionFile } from '../rpc.ts' +import type { ReadOfficeDocument } from './cache.ts' +import type { OfficeStore } from './store.ts' + +/** Conversion loading callback supplied to the Office body through Slot injection. */ +export interface OfficeLoadInjected { + /** + * Read one conversion revision; cancellation suppresses its store writes and version report. + * @param tabId - owning tab. + * @param revision - requested content revision. + * @param file - addressed Session and source path. + * @param signal - request lifetime, cancelled on replacement, unmount, or tab closure. + * @param loaded - report the displayed source version to the document owner. + * @param failed - report a failed load to the document owner. + */ + readonly load: ( + tabId: TabId, + revision: number, + file: SessionFile, + signal: AbortSignal, + loaded: (version: string) => void, + failed: () => void, + ) => void +} + +/** + * Bind Office reads to store actions without exposing Remote work to the component. + * @param read - authorized conversion reader, including Client cache reuse. + * @param describeFailure - localized conversion or source-access failure text. + * @returns Slot inject factory; the file address supplies the read's Session authority. + */ +export function officeFace( + read: ReadOfficeDocument, + describeFailure: (failure: RemoteFailure | { readonly message: string }) => string, +): (sessionId: SessionId, actions: BoundActions) => OfficeLoadInjected { + return (_sessionId, actions) => ({ + load: (tabId, revision, file, signal, loaded, failed) => { + if (signal.aborted) return + actions.loading(tabId, revision) + void read(file, signal).then((result) => { + if (signal.aborted) return + if (result.ok) { + actions.complete(tabId, revision, result.value) + loaded(result.value.version) + } else { + actions.failed(tabId, revision, { code: result.error.code, message: describeFailure(result.error) }) + failed() + } + }, (error: unknown) => { + if (!signal.aborted) { + actions.failed(tabId, revision, { + code: 'gateway/internal', message: describeFailure({ message: error instanceof Error ? error.message : String(error) }), + }) + failed() + } + }) + }, + }) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 098e5dd06c..34dcd60c83 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -14,6 +14,7 @@ import { pdfBodyRegistration } from '../pdf/index.ts' import { LazyPdfBody } from '../pdf/LazyPdfBody.tsx' import { OfficeBody, type OfficeBodyInjected } from './OfficeBody.tsx' import { OfficeFontAction } from './OfficeFontAction.tsx' +import { officeFace } from './face.ts' import { createOfficeStore } from './store.ts' import type { Config } from '../../config.ts' @@ -49,14 +50,17 @@ export function apply(ctx: Context, config: Config['office']): void { }, OfficeFontAction))) const retainTab = retainDocumentTabs(ctx) const documentT = ctx.locale.bind('sidebarDocumentPreview') + const face = officeFace( + (file, signal) => read(file, signal), + failure => 'code' in failure ? failureLine(documentT, failure) : documentT('error.unavailable', { message: failure.message }), + ) ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ name: 'sidebar.right.tab.document', key: id, locale: 'sidebarOffice', store, children: { 'sidebar.right.tab.document.office.pdf': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } }, } }, - inject: (_sessionId, actions): OfficeBodyInjected => ({ - read: (file, signal) => read(file, signal), - describeFailure: failure => 'code' in failure ? failureLine(documentT, failure) : documentT('error.unavailable', { message: failure.message }), + inject: (sessionId, actions): OfficeBodyInjected => ({ + ...face(sessionId, actions), retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) }, }), }, OfficeBody))) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts index 5e83803eae..653afc3062 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -1,6 +1,6 @@ /** Office preview copy and Host render configuration guidance. */ export const zh = { - title: 'Office 文档', + title: 'Office PDF', loading: '正在读取…', retry: '重试', viewMissingFonts: '缺失 {count} 种字体,点击查看', @@ -22,7 +22,7 @@ export type OfficePreviewKey = keyof typeof zh /** English translations checked against the Chinese key set. */ export const en = { - title: 'Office document', + title: 'Office PDF', loading: 'Reading…', retry: 'Retry', viewMissingFonts: 'Missing fonts: {count}. Click to view.', diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index ffa492509f..bed2275f85 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-office-to-pdf/remote' import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { apply } from '../src/client/office/index.ts' +import { officeFace } from '../src/client/office/face.ts' import { Config } from '../src/config.ts' import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx' import type { OfficeStore } from '../src/client/office/store.ts' @@ -15,6 +16,11 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { en, zh } from '../src/client/office/locales.ts' import { en as documentEn } from '../src/client/locales.ts' +vi.mock('../src/client/office/face.ts', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, officeFace: vi.fn(actual.officeFace) } +}) + const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' } const generation = ('renderer' as OfficeToPdfGeneration) const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' } @@ -40,8 +46,8 @@ it('retains Office view state across remounts and releases it on tab close or pl h.instance.actions.loading(second, 1) h.injected.retainTab(second, retained.signal) const message = documentEn['error.unavailable'].replace('{message}', 'conversion stopped') - expect(h.injected.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message) - expect(h.injected.describeFailure({ message: 'conversion stopped' })).toBe(message) + expect(h.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message) + expect(h.describeFailure({ message: 'conversion stopped' })).toBe(message) await h.close() expect(h.instance.getSnapshot().byTab[second]).toBeUndefined() } finally { closed.abort(); retained.abort(); await h.close() } @@ -74,9 +80,10 @@ async function harness(config: Partial = {}, missing?: 'remote const entry = recorded.find(entry => entry.component === OfficeBody)!.options const instance = entry.store.create() const injected = entry.inject(file.sessionId, instance.actions) - return { ctx, registry, instance, injected, recorded, locale, removeLocale, render, rendererGeneration, + const [read, describeFailure] = vi.mocked(officeFace).mock.calls.at(-1)! + return { ctx, registry, instance, injected, describeFailure, recorded, locale, removeLocale, render, rendererGeneration, stat, readBytes, register, removeNotice, - read: (signal = new AbortController().signal, path = file.path) => injected.read({ ...file, path }, signal), + read: (signal = new AbortController().signal, path = file.path) => read({ ...file, path }, signal), close: () => fiber.dispose(), } } diff --git a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx index 472a8192f2..8566142c5c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx @@ -11,10 +11,11 @@ import type { DocumentContent } from '../src/client/document/contract.ts' import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx' import { OfficeFontAction, type OfficeFontActionProps } from '../src/client/office/OfficeFontAction.tsx' import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx' +import { officeFace } from '../src/client/office/face.ts' import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts' import type { ReadOfficeDocument } from '../src/client/office/cache.ts' import { en } from '../src/client/office/locales.ts' -import { harness, ABSOLUTE_PATH, TAB_ID, settle } from './fixtures.client.ts' +import { harness, ABSOLUTE_PATH, ADDRESS, TAB_ID, SESSION, settle } from './fixtures.client.ts' beforeEach(() => { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) @@ -100,12 +101,12 @@ function setup() { function useOffice(selector: (state: OfficeState) => T): T { return selector(useSyncExternalStore(subscribe, snapshot)) } - const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message + const injected = officeFace(read, error => error.message)(SESSION, office.actions) let request: Extract | undefined const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => { if (key === 'sidebar.right.tab.document.action') { return , useTabInfo: options?.hookContext, useStore: useOffice, - actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} /> + actions: office.actions, t: makeTranslate(en) } as OfficeFontActionProps} /> } if (key !== 'sidebar.right.tab.document') return null const owner = input as OwnerOf<'sidebar.right.tab.document'> @@ -113,16 +114,16 @@ function setup() { request = owner.content // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario. const props = { ...h.props(), ...owner, useTabInfo: options?.hookContext, useStore: useOffice, - actions: office.actions, read, retainTab, describeFailure, + actions: office.actions, ...injected, retainTab, t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (

{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}

), - } as unknown as OfficeBodyProps + } as OfficeBodyProps return } - function View({ renderer = true }: { renderer?: boolean }) { + function View({ renderer = true, implementation = definition.id }: { renderer?: boolean; implementation?: string }) { return selector([renderer ? definition : { ...definition, id: 'raw', loading: 'bytes-complete' }])} /> + useDocumentPreviews={selector => selector([renderer ? { ...definition, id: implementation } : { ...definition, id: 'raw', loading: 'bytes-complete' }])} /> } onTestFinished(async () => { h.controller.abort() @@ -240,8 +241,10 @@ it.each(['declared', 'exception', 'foreign'] as const)('shows %s conversion fail it('starts no conversion when a body receives ordinary shared content', () => { const h = setup() const props = { ...h.h.props(), content: { kind: 'bytes', data: new Uint8Array() }, - useStore: () => undefined, read: h.read, retainTab: vi.fn(), describeFailure: vi.fn(), - } as unknown as OfficeBodyProps + resourceAddress: ADDRESS, wrap: false, scrollportRef: vi.fn(), + addResource: vi.fn(), setResources: vi.fn(), + useStore: () => undefined, actions: h.office.actions, load: vi.fn(), retainTab: vi.fn(), t: makeTranslate(en), + } as OfficeBodyProps const view = render() expect(view.container.childElementCount).toBe(0) expect(h.read).not.toHaveBeenCalled() @@ -269,3 +272,45 @@ it('shows the current revision’s missing fonts in the toolbar and clears stale expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() expect(screen.queryByRole('dialog')).toBeNull() }) + +it('isolates a replacement implementation with the same renderer-owned loading mode', async () => { + const h = setup() + const view = render() + const previous = h.request() + view.rerender() + expect(h.pending[0]!.signal.aborted).toBe(true) + expect(h.pending).toHaveLength(2) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') }) + expect(screen.getByText('PDF v2')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.contentRendererId).toBe('alternative') + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') +}) + +it('starts no conversion or store write for an already closed request', () => { + const read = vi.fn() + const store = createOfficeStore().create() + const face = officeFace(read, error => error.message)(SESSION, store.actions) + const controller = new AbortController() + controller.abort() + const loaded = vi.fn() + const failed = vi.fn() + face.load(TAB_ID, 1, { sessionId: SESSION, path: ABSOLUTE_PATH }, controller.signal, loaded, failed) + expect(read).not.toHaveBeenCalled() + expect(loaded).not.toHaveBeenCalled() + expect(failed).not.toHaveBeenCalled() + expect(store.getSnapshot().byTab[TAB_ID]).toBeUndefined() +}) + +it('keeps a pending conversion when metadata changes while automatic refresh is paused', async () => { + const h = setup() + const view = render() + act(() => { h.h.instance.actions.toggledAutoRefresh(TAB_ID) }) + h.h.setVersion('v2') + view.rerender() + expect(h.read).toHaveBeenCalledTimes(1) + expect(h.pending[0]!.signal.aborted).toBe(false) + await act(async () => { h.pending[0]!.deferred.resolve(result('v1')) }) + expect(screen.getByText('PDF v1')).toBeTruthy() + expect(screen.getByText('changed')).toBeTruthy() +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 2954e2b935..7660b40523 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3115,7 +3115,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.office.pdf\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.office.pdf\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:20', + source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:19', }, { key: 'sidebar.right.tab.document.unpreviewable', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 3a90b2606a..0cdde48383 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -744,11 +744,6 @@ "193e777770c611bf1005e2d5ce574b13e1469926c169812107cf091c73772c0b": 1, "ed59d2814914c377dda69ab030e0b51de776a34205ab85e6364fda6fbe903de5": 1 }, - "packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx": { - "3729313792d93c55cad1437e7ca1c5f9d5d6d4e02ab0000b0a3b01246504f898": 1, - "98dcb2e285933d18a1bc95899f5a46d545e3dba0873640e1920e711a254082dc": 1, - "a3a3404b2792d2353dc869625840b3166064d6f34100f366940ac686376783f8": 1 - }, "packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts": { "9173285d1c600b0eeb00bc3f852d2e3c8c46ea83cd36488a422d5fd05194935a": 1 }, From 15415600d1bd0c36a6ace17674747d9d19acb8d1 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 17:03:53 +0800 Subject: [PATCH 18/36] fix(preview): render spreadsheet values as literal text --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 6 ++- .../2026-09-16-browser-excel-preview.zh.md | 6 ++- THIRD_PARTY_NOTICES.md | 1 + apps/web/tests/document-preview.e2e.ts | 22 +++++++- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../tests/excel-fixture.ts | 15 ++++++ patches/@fortune-sheet__core@1.0.4.patch | 26 ++++++++++ patches/@fortune-sheet__react@1.0.4.patch | 50 ++++++++++++++++--- pnpm-lock.yaml | 13 ++--- pnpm-workspace.yaml | 1 + .../web/document-preview/document.expected.md | 1 + 14 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 patches/@fortune-sheet__core@1.0.4.patch diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 5e63afa914..2c6432adcf 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 6a33fbccdace9172fcee0863519b5c05d0595c7b -2026-09-16-browser-excel-preview.zh.md: 190102ed86925b28dea434d3c2f3ded4de11ddf6 +2026-09-16-browser-excel-preview.md: c31c563664d9c888a85d8b7fcb7f3abb50422f44 +2026-09-16-browser-excel-preview.zh.md: 09e24a28a76d00e60357b1c1acacdf82943fe567 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 6a33fbccda..c31c563664 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -16,7 +16,11 @@ Preview is read-only and retains formula text alongside saved results. It does n 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 [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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 CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. +The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) 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 ` 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, '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'), @@ -784,6 +785,24 @@ else process.exit(1); 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') + const formulaInput = excel.locator('.fortune-fx-input') + 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}`) @@ -795,6 +814,7 @@ else process.exit(1); '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', '- 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')) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 9c012fb5ea..767eda39c1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: 8c845b6236c637f340ce7d2be0fdc25f7950dcdc -README.zh.md: 4f1a5f55f35495ba51b55f67a7864396169a2304 +README.md: 7e9650c3e65763dfe5536077e19adffd7cc70e02 +README.zh.md: d391c95f6b942596c5665022448189d6a70aa2b1 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 8c845b6236..7e9650c3e6 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -78,7 +78,9 @@ Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits c | `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | | `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | -The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. +The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ExcelJS decodes entity spellings in cached XLSX string formula results again; a saved literal `<` is displayed as `<`. + +The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency. ## Office preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 4f1a5f55f3..d391c95f6b 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -78,7 +78,9 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别 | `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | | `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | -惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 +惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。ExcelJS 会再次解码 XLSX 字符串公式缓存结果中的实体写法;保存的字面文本 `<` 会显示为 `<`。 + +只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。 ## Office 预览 diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index 41ecf38986..ef9eefc42b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -47,3 +47,18 @@ export async function excelFixture(): Promise> { workbook.addWorksheet('隐藏页', { state: 'hidden' }).getCell('A1').value = 'Hidden data' return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Cell text whose markup and ampersand must remain literal in the preview and clipboard. */ +export const excelHtmlText = '& literal' + +/** + * Build a workbook carrying HTML-looking formulas, text, and saved formula results. + * @returns Complete XLSX file bytes. + */ +export async function excelHtmlFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Formula').getCell('A1').value = { formula: `"${excelHtmlText}"`, result: 'saved result' } + workbook.addWorksheet('Text').getCell('A1').value = excelHtmlText + workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText } + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/patches/@fortune-sheet__core@1.0.4.patch b/patches/@fortune-sheet__core@1.0.4.patch new file mode 100644 index 0000000000..64457ac131 --- /dev/null +++ b/patches/@fortune-sheet__core@1.0.4.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.esm.js b/dist/index.esm.js +index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3af3bfadc 100644 +--- a/dist/index.esm.js ++++ b/dist/index.esm.js +@@ -62866,7 +62866,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { + if (_.isNil(c_value)) { + c_value = ""; + } +- column += escapeHTMLTag(c_value); ++ column += _.escape(c_value); + } else { + var _style = ""; + if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { +diff --git a/dist/index.js b/dist/index.js +index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02546b0b5b 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -62876,7 +62876,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { + if (___default['default'].isNil(c_value)) { + c_value = ""; + } +- column += escapeHTMLTag(c_value); ++ column += ___default['default'].escape(c_value); + } else { + var _style = ""; + if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch index dd76b04e78..f9d10c03ed 100644 --- a/patches/@fortune-sheet__react@1.0.4.patch +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb87459dbe7 100644 +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..c76ab678d776641131f582a194b5008a73a19bf2 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -921,7 +921,7 @@ var InputBox = function InputBox() { @@ -11,7 +11,26 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 setContext(function (ctx) { var flowdata = getFlowdata(ctx); if (!_.isNil(flowdata) && ctx.forceFormulaRef) { -@@ -6908,7 +6908,8 @@ var SheetItem = function SheetItem(_ref) { +@@ -6733,12 +6733,16 @@ var FxEditor = function FxEditor() { + if (isInlineStringCell(cell)) { + value = getInlineStringNoStyle(r, c, d); + } else if (cell.f) { +- value = getCellValue(r, c, d, "f"); ++ value = context.allowEdit === false ? cell.f : getCellValue(r, c, d, "f"); + } else { + value = valueShowEs(r, c, d); + } + } +- refs.fxInput.current.innerHTML = escapeHTMLTag(escapeScriptTag(value)); ++ if (context.allowEdit === false) { ++ refs.fxInput.current.textContent = value; ++ } else { ++ refs.fxInput.current.innerHTML = escapeHTMLTag(escapeScriptTag(value)); ++ } + } else { + refs.fxInput.current.innerHTML = ""; + } +@@ -6908,7 +6912,8 @@ var SheetItem = function SheetItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -21,7 +40,7 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 } draftCtx.luckysheet_selection_range = []; }); -@@ -10693,7 +10694,8 @@ var SheetListItem = function SheetListItem(_ref) { +@@ -10693,7 +10698,8 @@ var SheetListItem = function SheetListItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -32,7 +51,7 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 draftCtx.luckysheet_selection_range = []; }); diff --git a/dist/index.js b/dist/index.js -index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c002653a92b 100644 +index e4045518773578e3faaa3a58985a5585f9683088..e559e7a600365993a2994746c84e56f64bbf0033 100644 --- a/dist/index.js +++ b/dist/index.js @@ -932,7 +932,7 @@ var InputBox = function InputBox() { @@ -44,7 +63,26 @@ index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c00 setContext(function (ctx) { var flowdata = core.getFlowdata(ctx); if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { -@@ -6919,7 +6919,8 @@ var SheetItem = function SheetItem(_ref) { +@@ -6744,12 +6744,16 @@ var FxEditor = function FxEditor() { + if (core.isInlineStringCell(cell)) { + value = core.getInlineStringNoStyle(r, c, d); + } else if (cell.f) { +- value = core.getCellValue(r, c, d, "f"); ++ value = context.allowEdit === false ? cell.f : core.getCellValue(r, c, d, "f"); + } else { + value = core.valueShowEs(r, c, d); + } + } +- refs.fxInput.current.innerHTML = core.escapeHTMLTag(core.escapeScriptTag(value)); ++ if (context.allowEdit === false) { ++ refs.fxInput.current.textContent = value; ++ } else { ++ refs.fxInput.current.innerHTML = core.escapeHTMLTag(core.escapeScriptTag(value)); ++ } + } else { + refs.fxInput.current.innerHTML = ""; + } +@@ -6919,7 +6923,8 @@ var SheetItem = function SheetItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -54,7 +92,7 @@ index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c00 } draftCtx.luckysheet_selection_range = []; }); -@@ -10704,7 +10705,8 @@ var SheetListItem = function SheetListItem(_ref) { +@@ -10704,7 +10709,8 @@ var SheetListItem = function SheetListItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914620af95..77aecfe1da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,8 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/react@1.0.4': 7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6 + '@fortune-sheet/core@1.0.4': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0 + '@fortune-sheet/react@1.0.4': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4543,10 +4544,10 @@ importers: version: link:../../util/workspace-path '@fortune-sheet/core': specifier: 1.0.4 - version: 1.0.4 + version: 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) '@fortune-sheet/react': specifier: 1.0.4 - version: 1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -21551,7 +21552,7 @@ snapshots: bessel: 1.0.2 jstat: 1.9.6 - '@fortune-sheet/core@1.0.4': + '@fortune-sheet/core@1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)': dependencies: '@fortune-sheet/formula-parser': 0.2.13 dayjs: 1.11.21 @@ -21565,9 +21566,9 @@ snapshots: '@formulajs/formulajs': 2.9.3 tiny-emitter: 2.1.0 - '@fortune-sheet/react@1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@fortune-sheet/react@1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@fortune-sheet/core': 1.0.4 + '@fortune-sheet/core': 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) '@types/regenerator-runtime': 0.13.8 immer: 9.0.21 lodash: 4.18.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd9e8fd510..5480d37563 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -97,6 +97,7 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch + '@fortune-sheet/core@1.0.4': patches/@fortune-sheet__core@1.0.4.patch '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 0da7b0e270..0ae42ec777 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -78,6 +78,7 @@ - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted - 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: This spreadsheet could not be opened. Check its format, contents, or password protection. From 767cb38567cfb01ef63752e26376d7f79e9214fb Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 13:08:10 +0800 Subject: [PATCH 19/36] fix(locales): update titles for Office document in Chinese and English --- .../ui-sidebar-documentpreview/src/client/office/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts index 653afc3062..5e83803eae 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -1,6 +1,6 @@ /** Office preview copy and Host render configuration guidance. */ export const zh = { - title: 'Office PDF', + title: 'Office 文档', loading: '正在读取…', retry: '重试', viewMissingFonts: '缺失 {count} 种字体,点击查看', @@ -22,7 +22,7 @@ export type OfficePreviewKey = keyof typeof zh /** English translations checked against the Chinese key set. */ export const en = { - title: 'Office PDF', + title: 'Office document', loading: 'Reading…', retry: 'Retry', viewMissingFonts: 'Missing fonts: {count}. Click to view.', From c890269a19f0d4dff5f5666885c63be7eb7f6c0d Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:16:21 +0800 Subject: [PATCH 20/36] feat(client): add definition-owned conversation groups --- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 24 ++ docs/subsystems/conversation.zh.md | 24 ++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/conversation.ts | 17 ++ .../src/client/contract/groups.ts | 119 ++++++++ .../src/client/contract/snapshot.ts | 2 +- .../src/client/conversation/assembler.ts | 128 +++++++-- .../src/client/conversation/assembly.ts | 8 +- .../src/client/conversation/group-registry.ts | 48 ++++ .../src/client/conversation/group-store.ts | 226 +++++++++++++++ .../src/client/conversation/location-index.ts | 26 +- .../ui-conversation/src/client/index.ts | 9 +- .../conversation-assembler.client.spec.ts | 4 + .../conversation-group-store.client.spec.ts | 170 ++++++++++++ .../tests/conversation-groups.client.spec.ts | 258 ++++++++++++++++++ .../tests/plan-review-panel.client.spec.tsx | 2 +- .../user-questions-composer.client.spec.tsx | 2 +- 20 files changed, 1043 insertions(+), 36 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/groups.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/group-registry.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/group-store.ts create mode 100644 packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/conversation-groups.client.spec.ts diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 5ea67b1086..efb7d6d43a 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -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: 0478b546154bddffaa7003df77a995fdcbfa9c39 +conversation.zh.md: 386e25dcfbaa2f9137f723d7d6a3b03963a1e5a2 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 4d6324e71b..0478b54615 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -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,29 @@ 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. + +## 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` | `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` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | +| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | +| `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | +| `GroupUpdate` | Optional `entries` replaces the complete root sequence; omission preserves it. `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` | 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. + +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. 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. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 49f2d7dbdc..386e25dcfb 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -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,29 @@ 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 映射为自己的焦点标识。 + +## Group Definition + +可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。 + +[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议: + +| 类型 | 含义 | +|---|---| +| `ConversationGroupDefinition` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 | +| `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | +| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | +| `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | +| `GroupUpdate` | 可选的 `entries` 替换完整根序列,省略则保留。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | +| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 | +| `ConversationGroupedView` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 | + +注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。 + +Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 + +渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 + ## 可回放 event family 编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2f52a3e505..e4719a752f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 496c60e29582573abe15253ad0efb46e5bd7125f +README.zh.md: 71d2afefd9b22e0af21d2cd496d53cda9ac6a46c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31b35ef746..496c60e295 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,6 +31,8 @@ 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 the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. 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. + 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1fb648bf95..71d2afefd9 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 +`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 + 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)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index d5cbe27108..333dd1e5ae 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -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: 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: Target, + ): ConversationGroupedView> | undefined } /** Immutable public view of an assembled business Context. */ @@ -261,6 +272,7 @@ export interface ConversationViewBuilder + /** 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. */ diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts new file mode 100644 index 0000000000..4a7e532c0c --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -0,0 +1,119 @@ +/** 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 { + readonly key: GroupKey + readonly data: Data + readonly members: readonly NodeReference[] +} + +/** + * One atomic grouping update. Omitted entries preserve the root list; + * supplied entries replace it. Group removal never deletes source Nodes. + */ +export interface GroupUpdate { + readonly entries?: readonly RenderEntry[] + readonly groups: + | { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot[] } + | { + readonly kind: 'apply' + readonly upserts: readonly GroupSnapshot[] + readonly removes: readonly GroupKey[] + } +} + +/** Target-processed Node values before and after one update. */ +export interface NodeChange { + readonly previous: Node | undefined + readonly current: Node +} + +interface GroupInputNodes { + readonly order: readonly NodeKey[] + /** @param key - target Node identity. @returns its current value during this synchronous update. */ + readonly readNode: (key: NodeKey) => Node | undefined + readonly timeline: ConversationTimelineSnapshot +} + +/** Target-local inputs; apply includes lifecycle changes without Node upserts. */ +export type ConversationGroupInput = GroupInputNodes & ( + | { readonly kind: 'replace' } + | { + readonly kind: 'apply' + readonly changes: readonly NodeChange[] + readonly changedTurns: readonly number[] + } +) + +/** Framework-owned State for one Session's registered Group Definition. */ +export interface ConversationGroupContext { + 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; readNode is valid only during this call. + * @returns State adopted by the framework. + */ + update(context: ConversationGroupContext, input: ConversationGroupInput): 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): GroupUpdate | 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 keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never + +/** Stable grouped reader; data-only updates preserve entries and member arrays. */ +export interface ConversationGroupedView { + 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 | undefined> +} diff --git a/packages/client/ui-conversation/src/client/contract/snapshot.ts b/packages/client/ui-conversation/src/client/contract/snapshot.ts index 4b3fa13a9f..9982999e30 100644 --- a/packages/client/ui-conversation/src/client/contract/snapshot.ts +++ b/packages/client/ui-conversation/src/client/contract/snapshot.ts @@ -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(), } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 5259d1af0f..68b5ad9316 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -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 +} + const PUBLICATION_RANK: Record = { 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() private readonly dependents = new Map>() private readonly views = new Map() + private readonly groups = new Map() + private readonly pendingGroupStores = new Set>() private readonly activeTargets = new Set() 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: Target, + ): ConversationGroupedView> | undefined { + return this.groups.get(target)?.store as ConversationGroupedView> | 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,26 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } private resetViewBuilders(): void { + const definitions = this.viewDefinitions.entries() + const targets = new Set(definitions.map(definition => definition.target)) + for (const definition of this.groupDefinitions.entries()) { + if (!targets.has(definition.target)) { + throw new Error(`conversation group target "${definition.target}" is not registered`) + } + } + 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, diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index b305c9e817..fe4d49505c 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -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() 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) diff --git a/packages/client/ui-conversation/src/client/conversation/group-registry.ts b/packages/client/ui-conversation/src/client/conversation/group-registry.ts new file mode 100644 index 0000000000..1e52ef8b96 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-registry.ts @@ -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 { + /** + * @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( + definition: ConversationGroupDefinition> + & { 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) + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/group-store.ts b/packages/client/ui-conversation/src/client/conversation/group-store.ts new file mode 100644 index 0000000000..2a38f2a4df --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-store.ts @@ -0,0 +1,226 @@ +/** 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 + +interface GroupSource { + readonly observable: ObservableSnapshot | undefined> + readonly publication: SnapshotStore | undefined> +} + +interface GroupChanges { + readonly upserts: Map> + readonly removes: Set +} + +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( + 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 implements ConversationGroupedView { + private root: readonly RenderEntry[] = [] + private rootGroups = new Set() + private readonly groups = new Map>() + private readonly placements = new Map() + private readonly sources = new Map>() + private readonly dirty = new Set() + + /** @returns the identity-stable ordered root references. */ + get entries(): readonly RenderEntry[] { + return this.root + } + + groupSource(key: GroupKey): ObservableSnapshot | 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, + 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() + 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).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 { + /* v8 ignore next -- an empty replacement never reads a Node reference. */ + this.prepareAndInstall({ entries: [], groups: { kind: 'replace', snapshots: [] } }, () => undefined) + } + + private collectRootGroups(entries: readonly RenderEntry[]): Set { + const keys = new Set() + 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): GroupChanges { + const upserts = new Map>() + const removes = new Set() + const add = (snapshot: GroupSnapshot): 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 } + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index f7bac8eb1c..2f6eb70d9b 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -183,6 +183,7 @@ export class ConversationLocationIndex { private readonly turnDataStores = new Map() private readonly stepDataStores = new Map() private readonly dirtyDataStores = new Set() + private readonly changedTurns = new Set() 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 } + this.changedTurns.add(turnNumber) const changed = new Set() 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 { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 73ddc6d7d3..998169610e 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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, 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' diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 1b12d7f1a0..7b6441f217 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -177,6 +177,8 @@ describe('ConversationNodeAssembler', () => { const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 }) const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }) index.rebuild([input(turnStart), input(stepStart)]) + expect(index.takeChangedTurns()).toEqual([1]) + expect(index.takeChangedTurns()).toEqual([]) const location = index.locationOf(stepStart) if (location.kind !== 'step') throw new Error('scope probe requires a Step Location') const source = location.step.data.source('scope-probe') @@ -192,6 +194,7 @@ describe('ConversationNodeAssembler', () => { }])).toBe(true) expect(source.getSnapshot()).toBe(initial) expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) index.publishData() expect(listener).toHaveBeenCalledOnce() @@ -204,6 +207,7 @@ describe('ConversationNodeAssembler', () => { }]) index.publishData() expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) const changed = { value: 2 } index.replaceData([{ diff --git a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts new file mode 100644 index 0000000000..2a165bfb93 --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { brandString } from '@deepseek-ai/dsh-brand' +import type { ConversationViewNode } from '../src/client/contract/conversation.ts' +import type { GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry } from '../src/client/contract/groups.ts' +import { ConversationGroupStore } from '../src/client/conversation/group-store.ts' + +const key = (value: string): GroupKey => brandString(value) +const node = (value: string, groupPart?: string): NodeReference => ({ + kind: 'node', key: brandString(value), ...groupPart === undefined ? {} : { groupPart }, +}) +const reference = (value: string): RenderEntry => ({ kind: 'group', key: key(value) }) +const readNode = (id: NodeKey): ConversationViewNode | undefined => id === 'missing' + ? undefined + : { key: id, kind: 'test', id, target: 'test', data: id } +const group = (id: string, members: readonly NodeReference[], data = 0): GroupSnapshot => ({ key: key(id), members, data }) + +function replace(store: ConversationGroupStore, entries: readonly RenderEntry[], groups: readonly GroupSnapshot[]) { + store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: groups } }, readNode) +} + +describe('ConversationGroupStore', () => { + it('publishes one changed group without changing root or member identities', () => { + const store = new ConversationGroupStore() + const a = group('a', [node('a')]) + const b = group('b', [node('b')]) + replace(store, [reference('a'), node('reply'), reference('b')], [a, b]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + const other = store.groupSource(key('b')) + expect(store.groupSource(key('a'))).toBe(source) + const changed = vi.fn() + const otherChanged = vi.fn() + const unsubscribe = source.subscribe(changed) + const unsubscribeOther = other.subscribe(otherChanged) + const reads = vi.fn(readNode) + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 1 }], removes: [] } }, reads) + expect(source.getSnapshot()).toEqual({ ...a, data: 1 }) + expect(source.getSnapshot()?.members).toBe(a.members) + expect(other.getSnapshot()).toBe(b) + expect(store.entries).toBe(root) + expect(reads).not.toHaveBeenCalled() + expect(changed).not.toHaveBeenCalled() + store.publish() + expect(changed).toHaveBeenCalledOnce() + expect(otherChanged).not.toHaveBeenCalled() + unsubscribe() + unsubscribeOther() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 2 }], removes: [] } }, readNode) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('reuses equivalent snapshots and accepts parts in separate positions', () => { + const store = new ConversationGroupStore() + const reasoning = node('assistant', 'reasoning') + const reply = node('assistant', 'response') + const original = group('a', [reasoning]) + replace(store, [reference('a'), reply], [original]) + const root = store.entries + const source = store.groupSource(key('a')) + const changed = vi.fn() + source.subscribe(changed) + store.publish() + replace(store, [reference('a'), node('assistant', 'response')], [group('a', [node('assistant', 'reasoning')])]) + store.publish() + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(original) + expect(changed).not.toHaveBeenCalled() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [group('a', [node('assistant', 'reasoning')], 1)], removes: [] } }, readNode) + expect(source.getSnapshot()?.members).toBe(original.members) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('moves references atomically and preserves surviving group sources', () => { + const store = new ConversationGroupStore() + const a = node('a') + const b = node('b') + replace(store, [reference('old')], [group('old', [a, b])]) + store.publish() + const oldSource = store.groupSource(key('old')) + const nextSource = store.groupSource(key('next')) + const oldChanged = vi.fn() + oldSource.subscribe(oldChanged) + store.prepareAndInstall({ + entries: [a, reference('next')], + groups: { kind: 'apply', removes: [key('old')], upserts: [group('next', [b])] }, + }, readNode) + expect(oldSource.getSnapshot()).toBeUndefined() + expect(nextSource.getSnapshot()?.members).toEqual([b]) + expect(oldChanged).not.toHaveBeenCalled() + store.publish() + expect(oldChanged).toHaveBeenCalledOnce() + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [group('next', [node('c')])] } }, readNode) + expect(store.entries).toBe(root) + expect(store.groupSource(key('next'))).toBe(nextSource) + store.prepareAndInstall({ entries: [node('c'), a], groups: { kind: 'apply', removes: [key('next')], upserts: [] } }, readNode) + store.publish() + expect(store.entries).toEqual([node('c'), a]) + store.clear() + store.publish() + expect(store.entries).toEqual([]) + expect(nextSource.getSnapshot()).toBeUndefined() + store.clear() + store.publish() + }) + + it('keeps omitted entries and allows an empty complete replacement', () => { + const store = new ConversationGroupStore() + replace(store, [node('a'), node('b')], []) + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toBe(root) + store.prepareAndInstall({ entries: [node('b'), node('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toEqual([node('b'), node('a')]) + replace(store, [], []) + expect(store.entries).toEqual([]) + }) + + it('validates references again on complete replacement, even when arrays are reused', () => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + const entries = [reference('a'), node('b')] + replace(store, entries, [initial]) + expect(() =>{ store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) + .toThrow('missing Node') + expect(store.entries).toBe(entries) + expect(store.groupSource(key('a')).getSnapshot()).toBe(initial) + }) + + it.each([ + [node('a'), node('a')], + [node('a', 'reasoning'), node('a', 'reasoning')], + [node('a'), node('a', 'reasoning')], + [node('a', 'reasoning'), node('a')], + ])('rejects overlapping Node positions without changing installed data: %j', (first, second) => { + const store = new ConversationGroupStore() + replace(store, [node('before')], []) + const root = store.entries + expect(() =>{ replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') + expect(store.entries).toBe(root) + replace(store, [reference('a')], [group('a', [first])]) + expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first]) + }) + + it.each<{ name: string; update: GroupUpdate; error: string }>([ + { name: 'duplicate group roots', update: { entries: [reference('a'), reference('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'duplicate root' }, + { name: 'unreferenced group', update: { entries: [reference('a'), reference('c')], groups: { kind: 'apply', upserts: [group('b', [])], removes: [] } }, error: 'no root reference' }, + { name: 'missing group', update: { entries: [reference('missing')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'missing group' }, + { name: 'removed referenced group', update: { groups: { kind: 'apply', upserts: [group('b', [])], removes: [key('a')] } }, error: 'still referenced' }, + { name: 'record count mismatch', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'one-to-one' }, + { name: 'duplicate upserts', update: { groups: { kind: 'apply', upserts: [group('a', []), group('a', [])], removes: [] } }, error: 'duplicate upserts' }, + { name: 'duplicate removals', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [key('a'), key('a')] } }, error: 'duplicate removals' }, + { name: 'absent removal', update: { groups: { kind: 'apply', upserts: [], removes: [key('missing')] } }, error: 'absent' }, + { name: 'upsert and remove', update: { groups: { kind: 'apply', upserts: [group('a', [])], removes: [key('a')] } }, error: 'upserted and removed' }, + { name: 'missing Node', update: { groups: { kind: 'apply', upserts: [group('a', [node('missing')])], removes: [] } }, error: 'missing Node' }, + ])('rejects $name atomically', ({ update, error }) => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + replace(store, [reference('a')], [initial]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + expect(() =>{ store.prepareAndInstall(update, readNode) }).toThrow(error) + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(initial) + }) +}) diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts new file mode 100644 index 0000000000..b10e8a420f --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -0,0 +1,258 @@ +import { Context } from '@deepseek-ai/cordis' +import { brandString } from '@deepseek-ai/dsh-brand' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { + ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import type { + ConversationGroupDefinition, ConversationGroupInput, GroupKey, NodeKey, +} from '../src/client/contract/groups.ts' +import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts' +import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + 'group-test': number + } +} + +const groupKey = brandString('group') +const noEvents = { entries: () => [], fallbackEntry: () => undefined } + +class GroupTestBuilder implements ConversationViewBuilder> { + readonly empty = new Map() + readonly nodes = new Map() + readonly publish = vi.fn() + private input: ConversationGroupInput = { + kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() }, + } + + replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + return this.nodes + } + + apply({ upserts, timeline, changedTurns = [] }: { + upserts: readonly ConversationViewNode[] + timeline: ConversationTimelineSnapshot + changedTurns?: readonly number[] + }) { + const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current })) + for (const node of upserts) this.nodes.set(node.key, node) + this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + return this.nodes + } + + groupInput() { return this.input } +} + +function grouping(seen: ConversationGroupInput[] = []) { + return { + kind: 'test-group', + target: 'group-test' as const, + create: (): ConversationGroupInput | null => null, + update: (_context, input) => { seen.push(input); return input }, + buildGroups: ({ state }) => state === null ? null : { + entries: [{ kind: 'group', key: groupKey }], + groups: { + kind: 'replace', + snapshots: [{ + key: groupKey, + data: state.timeline.turns.get(1)?.status === 'closed' ? 1 : 0, + members: state.order.map(key => ({ kind: 'node', key })), + }], + }, + }, + } satisfies ConversationGroupDefinition | null, number> +} + +function start(seq: number, turn = 1): SessionEvent<'turn/start'> { + return { seq: SessionSeq(seq), time: seq, type: 'turn/start', data: { turn } } +} + +function setup(definition: ConversationGroupDefinition = grouping()) { + const builder = new GroupTestBuilder() + const create = vi.fn(() => builder) + let groupDefinitions = [definition] + const views = [{ target: 'group-test', create }] + const groups = { + entries: () => groupDefinitions, + forTarget: (target: string) => groupDefinitions.find(candidate => candidate.target === target), + } + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => views }, groups) + return { + assembler, builder, create, views, + removeGroups: () => { groupDefinitions = [] }, + replaceGroup: (next: ConversationGroupDefinition) => { groupDefinitions = [next] }, + } +} + +describe('group Definition dispatch', () => { + it('creates grouping on first activation without eagerly constructing a Builder', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder, create } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + expect(assembler.flush()).toBe(false) + expect(create).not.toHaveBeenCalled() + expect(assembler.grouped('group-test')).toBeUndefined() + builder.publish.mockImplementation(() => { + expect(assembler.snapshot('group-test')).toBe(builder.nodes) + expect(assembler.grouped('group-test')?.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + expect(assembler.activateTarget('group-test')).toBe(true) + expect(create).toHaveBeenCalledOnce() + expect(seen.map(input => input.kind)).toEqual(['replace']) + expect(builder.publish).toHaveBeenCalledOnce() + expect(assembler.activateTarget('group-test')).toBe(false) + expect(seen).toHaveLength(1) + }) + + it('delivers lifecycle changes without Node upserts and installs groups before publication', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const entries = grouped.entries + const source = grouped.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + const end: SessionEvent<'turn/end'> = { type: 'turn/end', seq: SessionSeq(2), time: 2, data: { turn: 1, reason: { kind: 'completed' } } } + assembler.append({ type: 'event', event: end }) + builder.publish.mockImplementation(() => { expect(source.getSnapshot()?.data).toBe(1) }) + assembler.flush() + const input = seen.at(-1)! + expect(input.kind).toBe('apply') + if (input.kind !== 'apply') throw new Error('expected apply') + expect(input.changes).toEqual([]) + expect(input.changedTurns).toEqual([1]) + expect(grouped.entries).toBe(entries) + expect(changed).toHaveBeenCalledOnce() + expect(assembler.flush()).toBe(false) + }) + + it('rebuilds through the same path and invalidates sources when grouping is removed', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, removeGroups } = setup(grouping(seen)) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + assembler.rebuildRegistry() + assembler.flush() + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(assembler.grouped('group-test')!.groupSource(groupKey)).toBe(source) + removeGroups() + assembler.rebuildRegistry() + assembler.flush() + expect(source.getSnapshot()).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + }) + + it('does not ask ungrouped Builders for grouping input', () => { + const builder = new GroupTestBuilder() + const input = vi.spyOn(builder, 'groupInput') + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create: () => builder }] }) + assembler.activateTarget('group-test') + expect(input).not.toHaveBeenCalled() + }) + + it('keeps the installed group context until the registry rebuild replaces it', () => { + const first = grouping() + const firstUpdate = vi.spyOn(first, 'update') + const next = grouping() + const nextCreate = vi.spyOn(next, 'create') + const { assembler, replaceGroup } = setup(first) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + replaceGroup(next) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(firstUpdate).toHaveBeenCalledTimes(2) + expect(nextCreate).not.toHaveBeenCalled() + assembler.rebuildRegistry() + assembler.flush() + expect(nextCreate).toHaveBeenCalledOnce() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + expect(assembler.grouped('group-test')!.groupSource(groupKey)).not.toBe(source) + }) + + it('keeps published grouping when a later buildGroups returns null', () => { + const definition = grouping() + const build = vi.spyOn(definition, 'buildGroups') + const { assembler } = setup(definition) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const root = grouped.entries + const source = grouped.groupSource(groupKey) + const value = source.getSnapshot() + const changed = vi.fn() + source.subscribe(changed) + build.mockReturnValueOnce(null) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(grouped.entries).toBe(root) + expect(source.getSnapshot()).toBe(value) + expect(changed).not.toHaveBeenCalled() + }) + + it('rejects a missing Builder input method only after target activation', () => { + const create = vi.fn(() => ({ empty: null, replace: () => null, apply: () => null })) + const definition = grouping() + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create }] }, { + entries: () => [definition], forTarget: () => definition, + }) + expect(create).not.toHaveBeenCalled() + expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()') + }) + + it('rejects missing targets and incomplete initial output', () => { + const definition = grouping() + expect(() => new ConversationNodeAssembler(noEvents, { entries: () => [] }, { + entries: () => [definition], forTarget: () => definition, + })).toThrow('is not registered') + const missing = setup({ ...definition, buildGroups: () => null }) + expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + const retained = grouping() + const build = vi.spyOn(retained, 'buildGroups') + const { assembler } = setup(retained) + assembler.activateTarget('group-test') + build.mockReturnValueOnce(null) + assembler.replaceWindow([], false) + expect(() => assembler.flush()).toThrow('complete grouping for replacement input') + }) + + it('registers grouping without creating its Builder and disposes it with its effect', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const views = new ConversationViewRegistry(ctx) + const groups = new ConversationGroupRegistry(ctx, views) + const definition = grouping() + expect(() => groups.register(definition)).toThrow('is not registered') + const create = vi.fn(() => new GroupTestBuilder()) + views.register({ target: 'group-test', create } satisfies ConversationViewDefinition) + const changed = vi.fn() + const unsubscribe = groups.subscribe(changed) + const dispose = groups.register(definition) + expect(create).not.toHaveBeenCalled() + expect(groups.forTarget('group-test')).toBe(definition) + expect(() => groups.register(definition)).toThrow('already registered') + dispose() + dispose() + expect(groups.forTarget('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledTimes(2) + unsubscribe() + groups.register(definition) + await ctx.fiber.dispose() + expect(groups.entries()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index fff2d81a6b..c645d276d8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -64,7 +64,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index ac9fe34d22..ef55a45abb 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -62,7 +62,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] From 1b3b56c8f14cb14807b4ba399a05c5dccbff04ff Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:17:14 +0800 Subject: [PATCH 21/36] feat(client): render stable conversation group references --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 8 ++ packages/client/ui-chat/README.zh.md | 8 ++ packages/client/ui-chat/package.json | 1 + packages/client/ui-chat/src/client/apply.ts | 3 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 29 ++++ .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 14 +- .../ui-chat/src/client/chat/ChatView.tsx | 33 +++-- .../ui-chat/src/client/chat/render-entry.ts | 16 +++ .../ui-chat/src/client/contract/slots.ts | 5 + .../chat-snapshot-builder.ts | 37 ++++- .../tests/chat-node-source.client.spec.ts | 34 +++++ .../ui-chat/tests/chat-view.client.spec.tsx | 132 +++++++++++++++++- packages/client/ui-chat/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 12 +- pnpm-lock.yaml | 3 + 16 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx create mode 100644 packages/client/ui-chat/src/client/chat/render-entry.ts diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1eefd3ba94..5589484177 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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: 64ca5bb9d6da1fa6ea34b5022d2f045c0a816d5d +README.zh.md: 17885c3cf954630e1210c377d621b6612ce6735d diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 2beb4dfadd..64ca5bb9d6 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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,13 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s ----- + +## 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 different parts. 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. + +----- + ## Scroll ownership diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 5d643f4e90..17885c3cf9 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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,13 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ----- + +## 分组渲染 + +Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有不同 DOM 锚点。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 + +----- + ## 滚动归属 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index f966be3c21..28fa4da05d 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -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", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 70390ec889..da926ba05a 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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 diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx new file mode 100644 index 0000000000..a4f4c1bf1d --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -0,0 +1,29 @@ +/** 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' + +type ChatGroupSeatProps = Omit, '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 ( +
+ {members.map(member => ( + + ))} +
+ ) +}) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 3ed6f7b8d8..081a1d768d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -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(() => 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,14 @@ 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 (
, 'nodeKey'> & { - readonly order: readonly string[] + readonly entries: readonly RenderEntry[] + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } -const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) { - return order.map(nodeKey => ( - - )) +const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) { + return entries.map((entry) => { + switch (entry.kind) { + case 'node': + return + case 'group': + return + 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(() => 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({ )} 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> | undefined> | undefined } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 8c805e6107..7f5844ce2a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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, 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' @@ -966,12 +966,20 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder + private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(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, + timeline: this.empty.timeline, + } } replace(input: { @@ -985,21 +993,29 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] + const changes: NodeChange[] = [] 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 @@ -1028,11 +1044,26 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { + return this.latestGroupInput + } + + publish(): void { + this.store.publish() + } + private snapshot( timeline: ConversationTimelineSnapshot, legacy = this.legacy.replace(EMPTY_LIST, timeline), diff --git a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts index 1209380799..1b5030e537 100644 --- a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts @@ -28,6 +28,37 @@ 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) + 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.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: [] }) + 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 +74,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]) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index db60f029a9..a4151bf9ba 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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 | undefined + const absentGroup = createSnapshotStore | undefined>(undefined) + const conversation = createSnapshotStore({ + ...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>().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 | undefined) => { + grouped = value + conversation.set({ ...conversation.getSnapshot() }) + }, setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, setNodeRenderer: (renderer: React.ComponentProps['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 { @@ -582,6 +609,104 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + 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() + 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 + }) as ChatViewSlotProps['renderSlot']) + const view = render() + const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement + const parent = input.closest('[data-chat-group-key]') + expect(parent).not.toBeNull() + 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() + 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 {(owner as RoutedChatNodeOwner).groupPart} + }) as ChatViewSlotProps['renderSlot']) + const view = render() + 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) + }) + + 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() + 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() + 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 +2000,7 @@ describe('ChatView', () => { act(() => { turnData.publish() + builder.publish() h.set({ chat: complete, hasMore: false }) }) expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 92dffe85b0..ab6915a8a6 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/values" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index af3043fd78..a982323613 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:243', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:248', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:231', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:236', }, { key: 'conversation.chat.node', @@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:212', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:217', }, { key: 'conversation.chat.turnTail', @@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:237', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:242', }, { key: 'conversation.composer', @@ -1165,7 +1165,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:225', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:230', }, { key: 'conversation.plan-review.actions', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a78e83473..8498acad86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2912,6 +2912,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From a7d457349f5034ee66b47adf491cae9a7f295291 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:18:21 +0800 Subject: [PATCH 22/36] docs(client): record the conversation grouping foundation --- ...sentation-policy-and-step-groups.i18n.yaml | 4 +- ...hat-presentation-policy-and-step-groups.md | 2 + ...-presentation-policy-and-step-groups.zh.md | 2 + ...-09-21-conversation-build-groups.i18n.yaml | 6 + .../2026-09-21-conversation-build-groups.md | 185 ++++++++++++++++++ ...2026-09-21-conversation-build-groups.zh.md | 185 ++++++++++++++++++ 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml index bb3a7b1cbe..e6ea7582a7 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -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: 3057403c8c9055f4fbc631ba0b4ca0bae2fcd2de +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: f5b30bf96e9ca78b1f9eaacee1a62dd7cb3ec3da diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md index df0397a056..3057403c8c 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -25,6 +25,8 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit ## Goals and constraints +The [Definition-owned Build Group proposal](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 | diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index 1bf9cf9517..f5b30bf96e 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -25,6 +25,8 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不 ## 目标与约束 +[Definition 拥有分组的 Build Group 提案](2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 + 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 | 编号 | 裁决 | 含义 | diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml new file mode 100644 index 0000000000..e15c551a3b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -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/proposed/architecture/2026-09-21-conversation-build-groups.md +2026-09-21-conversation-build-groups.md: 1b012ebdaae7de59fe92ab80ccc42576c1d05f07 +2026-09-21-conversation-build-groups.zh.md: e26a073d55fb01ad9307171e20c7db00b04a1bd8 diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md new file mode 100644 index 0000000000..1b012ebdaa --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md @@ -0,0 +1,185 @@ +# Agent Note: Definition-owned grouping with generic Node/Group assembly + +Status: proposed + +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. + +## Proposal + +The foundation implements 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 remain a separate business phase; this note stays proposed until that phase is complete. 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. + +### Agreed 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](../../implemented/architecture/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](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](../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. 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, synchronous `readNode`, and timeline; `apply` also supplies projected previous/current Nodes and `changedTurns`. The reader is 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 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` 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` | Keep root order. | +| 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, and retains GroupInput 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. + +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 and stable order; 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. | +| Business phase: process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | +| Business phase: AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | + +## Business phase: segmentation and incremental work + +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. + +## 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, while its future header independently selects data. 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; modes change header visibility, body visibility, and styles only. + +| 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 the business phase. + +## 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. + +## Acceptance criteria + +1. Builder, assembler, and GroupStore contain no Chat segmentation/category rules or concrete grouping class construction. +2. Group State owns membership, segmentation, summaries, and business invalidation. +3. Ungrouped targets retain their path; duplicate registration and missing input fail without eager Builder construction. +4. Group input carries final projected Nodes, previous/current values, stable order, and lifecycle-only Turn changes. +5. Data-only upserts preserve entries and members, avoid unrelated scans, and notify only changed group sources. +6. All related Nodes, Groups, and Location data are installed before publication; invalid or overlapping references fail before Group installation. +7. First activation, append, settlement, replacement, prepend, and registry reconstruction use the common grouping path. +8. Registration replacement/removal leaves no old Group context or observed output; replacement Node stores rebind mounted members. +9. React renders only node/group root kinds and retains keys and parents across modes; local state survives mode changes. +10. Data types remain correlated through Definition, store, keyed reading, and business rendering. +11. Empty versus omitted entries, Group removal versus Node retention, and undefined removed-group sources retain their documented distinctions. +12. The separate business phase covers replies, steering, retries, no-Turn rows, Turn closure, summaries, and all three modes before claiming PR #4565 behavior complete. + +## Risks + +- 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. The Definition must distinguish content growth from structural changes and index the affected ranges. +- 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. +- Part-specific navigation anchors, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md new file mode 100644 index 0000000000..e26a073d55 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md @@ -0,0 +1,185 @@ +# Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出 + +Status: proposed + +[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 已经解释完成的节点。 + +## Proposal + +基础阶段实现独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。真实 Chat 分段及 PR #4565 展示仍属于独立业务阶段;该阶段完成前,本记录保留 proposed 状态。[子系统参考](../../../../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 分组、摘要、通知或页脚变化。 | + +### 与已有记录的关系 + +| 记录 | 关系 | +|---|---| +| [业务节点组装](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | +| [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | +| [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | + +这些记录仍有独立理由,保留有效状态。导航与 `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` 和时间线;`apply` 还提供投影后的节点前后值及 `changedTurns`。读取函数仅在当前同步调用中有效,不保留到 State。 +- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。 +- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。 +- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。 + +## Node/Group 引用与更新 + +`NodeKey` 表示既有 Context Node,`GroupKey` 在 Session、目标及 Definition 内标识组。`NodeReference` 与 `GroupReference` 在 `RenderEntry` 中地位相同。`GroupSnapshot` 单独保存组的不可变数据与有序 NodeReference 成员,不混入根顺序。创建 Group 不增加 Session 事件或执行生命周期。 + +`groupPart` 是渲染器拥有的节点部分,例如 reasoning 或 response,省略表示整个 Node。部分引用也可以独立位于根层。部分内容的完备性和归属由业务渲染器负责,不由框架判断。组数据描述状态、计数或摘要,不复制 Node 正文、工具结果或可变业务 State。组不携带 renderer 字段、CSS、坐标、React 元素、导航或嵌套组。 + +| 更新 | 含义 | +|---|---| +| 省略 `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 实例。 + +分组不重放原始事件。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 引擎。 | +| 业务阶段:process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | +| 业务阶段:AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | + +## 业务阶段:分段与增量 + +业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 + +| 有序可见输入 | 业务动作 | +|---|---| +| 所属 Turn 改变 | 关闭待完成组。 | +| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | +| turn-process 控件 | 独立输出,不关闭待完成过程。 | +| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | +| Assistant 推理 | 把 reasoning 部分加入待完成组。 | +| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | +| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | +| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | + +分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 + +## React 读取与展示 + +根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members,后续组头独立选择 data。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 + +React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 + +| 模式 | 组头 | 正文 | +|---|---|---| +| Compact | 简短活动摘要 | 按组开合状态显隐。 | +| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | +| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | + +以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于业务阶段。 + +## 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 不变,父级改变仍会重挂载成员。 + +## Acceptance criteria + +1. Builder、assembler 和 GroupStore 不含 Chat 分段或类别规则,不固定创建具体分组类。 +2. Group State 拥有成员、分段、摘要和业务失效规则。 +3. 未分组目标保持原路径;重复注册及缺少输入报错,不提前构造 Builder。 +4. 分组输入包含投影后的最终节点、前后值、稳定顺序及仅生命周期的轮次变化。 +5. 仅数据 upsert 保留 entries 和 members,不扫描无关内容,只通知变化组来源。 +6. 相关 Node、Group 和 Location 数据先全部安装再发布;无效及重叠引用在 Group 安装前报错。 +7. 首次激活、append、settlement、replace、prepend 和注册重建使用统一分组路径。 +8. 注册替换或删除不遗留旧 Group 上下文及观察结果;Node 存储替换时重绑定已挂载成员。 +9. React 根层只有 node/group,模式切换保留 key、父级及局部状态。 +10. Definition、存储、按键读取及业务渲染贯穿同一 Data 类型。 +11. 空与省略 entries、删除 Group 与保留 Node、已删除组返回 undefined 均保持协议规定的区别。 +12. 独立业务阶段覆盖回复、steering、重试、无 Turn 行、Turn 结束、摘要和三档展示后,才能宣称 PR #4565 产品行为完成。 + +## Risks + +- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 +- 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。 +- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 +- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 +- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 +- 部分导航锚点、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 From 0076de86fc423776a1017823f6e77fa7b5645432 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:28:16 +0800 Subject: [PATCH 23/36] test(client): cover group injection and conversation fixtures --- .../tests/apply-inject.client.spec.tsx | 32 +++++++++++++++++++ .../tests/chat-ptc-subcalls.client.spec.tsx | 9 ++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index dff3bbe376..cb16fa6a82 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -3,6 +3,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { brandString } from '@deepseek-ai/dsh-brand' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { @@ -12,6 +13,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, @@ -127,6 +129,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 = brandString('injected-group') + 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) diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 54212fa981..973aceae7f 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -11,7 +11,7 @@ import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { - ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps, + ConversationEventRegistry, ConversationViewRegistry, EMPTY_CONVERSATION_SNAPSHOT, type ConvViewOwnerProps, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -102,14 +102,19 @@ async function bench(snapshot: ChatSnapshot) { runtimes.push(runtime) const ctx = runtime.ctx const chat = createSnapshotStore(snapshot) + const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) const events = new ConversationEventRegistry(ctx) const views = new ConversationViewRegistry(ctx) ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) ctx.provide('uiConversation', { events, views, - binding: () => ({ target: () => chat }), + binding: () => ({ target: () => chat, snapshot: conversation }), } as never) + ctx.uiSession.provide({ + hooks: ['conversation'], + resolve: () => ({ hooks: { conversation } }), + }) await runtime.sessions.add({ id: SID, From c1c3410cf09c550b9df412061852f1400c0fb992 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:32:19 +0800 Subject: [PATCH 24/36] fix(benchmarks): declare grouping runtime dependency --- benchmarks/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/benchmarks/package.json b/benchmarks/package.json index fe74d6f65b..ad685bcb27 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -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:^" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8498acad86..1047eb63b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -825,6 +825,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../packages/typert/protocol + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../packages/util/values '@xterm/headless': specifier: ^6.0.0 version: 6.0.0 From b3549ee45590de5dd1536c78bbd3ca2896791b2c Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 12:11:47 +0800 Subject: [PATCH 25/36] fix(client): preserve grouped chat navigation and view lifecycle --- ...-09-21-conversation-build-groups.i18n.yaml | 4 +- .../2026-09-21-conversation-build-groups.md | 8 +- ...2026-09-21-conversation-build-groups.zh.md | 8 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 4 +- docs/subsystems/conversation.zh.md | 4 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- .../src/client/chat/ChatGroupSeat.module.css | 3 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 + .../ui-chat/src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/use-chat-viewport.ts | 13 +- .../tests/apply-inject.client.spec.tsx | 9 +- .../ui-chat/tests/chat-view.client.spec.tsx | 21 ++++ .../tests/chat-viewport.client.spec.ts | 114 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/groups.ts | 6 +- .../src/client/conversation/assembler.ts | 5 - .../src/client/conversation/group-store.ts | 7 +- .../tests/conversation-groups.client.spec.ts | 47 +++++++- 24 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css create mode 100644 packages/client/ui-chat/tests/chat-viewport.client.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml index e15c551a3b..a11e052d9a 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -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-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: 1b012ebdaae7de59fe92ab80ccc42576c1d05f07 -2026-09-21-conversation-build-groups.zh.md: e26a073d55fb01ad9307171e20c7db00b04a1bd8 +2026-09-21-conversation-build-groups.md: 9d7c233346e04218821719b43cfa8abc21651854 +2026-09-21-conversation-build-groups.zh.md: 6736a65e24b99cb2b3bd3d3bd20cd93a92bb63aa diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md index 1b012ebdaa..9d7c233346 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md @@ -36,7 +36,7 @@ The foundation implements a separate, Node-input `ConversationGroupDefinition`, | [Presentation policy and grouping](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](../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. Navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. +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 @@ -88,6 +88,8 @@ GroupStore indexes records and sources by GroupKey, not repeated array searches. 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 @@ -132,6 +134,8 @@ The root selects `views.grouped('chat')?.entries` through the existing useConver 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; modes change header visibility, body visibility, and styles only. +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. + | Mode | Group header | Body | |---|---|---| | Compact | Short activity summary | Group open/closed state controls visibility. | @@ -182,4 +186,4 @@ This assumes the outer Turn process is open. Expanded retains whole-Turn folding - 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. -- Part-specific navigation anchors, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. +- 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. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md index e26a073d55..6736a65e24 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md @@ -36,7 +36,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | | [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | -这些记录仍有独立理由,保留有效状态。导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 +这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 ## Definition 输入、状态与注册 @@ -88,6 +88,8 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 首次激活的 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 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。 ## 具体实现落点 @@ -132,6 +134,8 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 +稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。 + | 模式 | 组头 | 正文 | |---|---|---| | Compact | 简短活动摘要 | 按组开合状态显隐。 | @@ -182,4 +186,4 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 -- 部分导航锚点、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 +- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index efb7d6d43a..e6c640b8fa 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -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: 0478b546154bddffaa7003df77a995fdcbfa9c39 -conversation.zh.md: 386e25dcfbaa2f9137f723d7d6a3b03963a1e5a2 +conversation.md: 29ed4aa85ebdcc9061f33d620e26119fe7a9f927 +conversation.zh.md: 2aba5ab97dd93a5561e4790da87aa72939ebe322 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 0478b54615..29ed4aa85e 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -40,7 +40,7 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou | `ConversationGroupInput` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | | `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | | `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | -| `GroupUpdate` | Optional `entries` replaces the complete root sequence; omission preserves it. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | +| `GroupUpdate` | `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` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. | @@ -48,7 +48,7 @@ The Builder exposes `groupInput()` when grouping is registered; missing input fa 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. 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. +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 diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 386e25dcfb..2aba5ab97d 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -40,7 +40,7 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro | `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | | `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | | `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | -| `GroupUpdate` | 可选的 `entries` 替换完整根序列,省略则保留。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | +| `GroupUpdate` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | | `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 | | `ConversationGroupedView` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 | @@ -48,7 +48,7 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 -渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 +渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 ## 可回放 event family diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5589484177..ee8dc73bfc 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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: 64ca5bb9d6da1fa6ea34b5022d2f045c0a816d5d -README.zh.md: 17885c3cf954630e1210c377d621b6612ce6735d +README.md: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c +README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 64ca5bb9d6..0fd407f33b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -89,7 +89,9 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s ## 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 different parts. 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. +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. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 17885c3cf9..e9d5005851 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -89,7 +89,9 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ## 分组渲染 -Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有不同 DOM 锚点。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 +Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 + +组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。 ----- diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css new file mode 100644 index 0000000000..94ce0b8455 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -0,0 +1,3 @@ +.root { + display: contents; +} diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index a4f4c1bf1d..a5af681ef5 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -4,6 +4,7 @@ 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, 'nodeKey' | 'groupPart'> & { readonly groupKey: GroupKey @@ -15,7 +16,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const members = useChatGroup(groupKey, group => group?.members) if (members === undefined) return null return ( -
+
{members.map(member => ( ))} -
+
) }) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 081a1d768d..bb62567364 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -138,6 +138,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ className={css.flowItem} 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} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index d948993a3c..a712ae4d8d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -99,7 +99,7 @@ function TurnStatus({ startTime, t }: { ) } -type ChatNodeListProps = Omit, 'nodeKey'> & { +type ChatNodeListProps = Omit, 'nodeKey' | 'groupPart'> & { readonly entries: readonly RenderEntry[] readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index b4c1735707..2be7eb66a1 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -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('[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('[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( - '[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('[data-chat-turn]:not([hidden])')) { + for (const row of this.elements.list.querySelectorAll('[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) } diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index cb16fa6a82..9728a0b409 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -3,7 +3,6 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { brandString } from '@deepseek-ai/dsh-brand' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { @@ -24,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 @@ -133,7 +138,7 @@ describe('Chat inject API', () => { const b = await bench() try { const { injected } = b.chatViewApi(b.rootReference) - const key = brandString('injected-group') + const key = 'injected-group' as GroupKey expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() const conversation = b.runtime.ctx.uiConversation const remove = conversation.groups.register({ diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index a4151bf9ba..9ff18e1ab8 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -609,6 +609,24 @@ 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() + groupStore.prepareAndInstall({ + entries: [{ kind: 'node', key: included as NodeKey }], + groups: { kind: 'replace', snapshots: [] }, + }, id => snapshot.nodes.get(id)) + h.setGrouped(groupStore) + + const view = render() + 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) @@ -632,6 +650,7 @@ describe('ChatView', () => { 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) }) @@ -675,6 +694,8 @@ describe('ChatView', () => { 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', () => { diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts new file mode 100644 index 0000000000..09303fdde9 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -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) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e4719a752f..252ea7674a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 496c60e29582573abe15253ad0efb46e5bd7125f -README.zh.md: 71d2afefd9b22e0af21d2cd496d53cda9ac6a46c +README.md: 41a9baeb7cc370cdb4a9d6702808646a0df38fb3 +README.zh.md: 1b5604f0c0c074ac64a28da61706be5d663fda85 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 496c60e295..41a9baeb7c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -33,6 +33,8 @@ A target becomes active when shell selection resolves it or when its source rece `UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 71d2afefd9..1b5604f0c0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -33,6 +33,8 @@ shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 `UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。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)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts index 4a7e532c0c..37864617a8 100644 --- a/packages/client/ui-conversation/src/client/contract/groups.ts +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -33,10 +33,12 @@ export interface GroupSnapshot { } /** - * One atomic grouping update. Omitted entries preserve the root list; - * supplied entries replace it. Group removal never deletes source Nodes. + * 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 { + /** 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[] } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 68b5ad9316..d51216de32 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -994,11 +994,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private resetViewBuilders(): void { const definitions = this.viewDefinitions.entries() const targets = new Set(definitions.map(definition => definition.target)) - for (const definition of this.groupDefinitions.entries()) { - if (!targets.has(definition.target)) { - throw new Error(`conversation group target "${definition.target}" is not registered`) - } - } for (const [target, group] of this.groups) { if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) { group.store.clear() diff --git a/packages/client/ui-conversation/src/client/conversation/group-store.ts b/packages/client/ui-conversation/src/client/conversation/group-store.ts index 2a38f2a4df..a56e769bc7 100644 --- a/packages/client/ui-conversation/src/client/conversation/group-store.ts +++ b/packages/client/ui-conversation/src/client/conversation/group-store.ts @@ -174,8 +174,11 @@ export class ConversationGroupStore implements ConversationGroupedView undefined) + 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 { diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts index b10e8a420f..78a95a6185 100644 --- a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -6,7 +6,7 @@ import type { ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode, } from '../src/client/contract/conversation.ts' import type { - ConversationGroupDefinition, ConversationGroupInput, GroupKey, NodeKey, + ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupUpdate, NodeKey, } from '../src/client/contract/groups.ts' import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts' import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts' @@ -214,11 +214,8 @@ describe('group Definition dispatch', () => { expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()') }) - it('rejects missing targets and incomplete initial output', () => { + it('requires complete grouping for replacement input', () => { const definition = grouping() - expect(() => new ConversationNodeAssembler(noEvents, { entries: () => [] }, { - entries: () => [definition], forTarget: () => definition, - })).toThrow('is not registered') const missing = setup({ ...definition, buildGroups: () => null }) expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') const retained = grouping() @@ -230,6 +227,46 @@ describe('group Definition dispatch', () => { expect(() => assembler.flush()).toThrow('complete grouping for replacement input') }) + it.each>([ + { groups: { kind: 'replace', snapshots: [] } }, + { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, + ])('rejects incomplete grouping for replacement input: %j', (update) => { + const { assembler } = setup({ ...grouping(), buildGroups: () => update }) + expect(() => assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + }) + + it('pauses grouping when its View disappears and rebuilds when the View returns', () => { + const seen: ConversationGroupInput[] = [] + const definition = grouping(seen) + const createGroup = vi.spyOn(definition, 'create') + const { assembler, views, create } = setup(definition) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + + const view = views.pop()! + assembler.rebuildRegistry() + expect(assembler.flush()).toBe(true) + expect(assembler.snapshot('group-test')).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(seen).toHaveLength(1) + expect(createGroup).toHaveBeenCalledOnce() + views.push(view) + assembler.rebuildRegistry() + assembler.flush() + expect(create).toHaveBeenCalledTimes(2) + expect(createGroup).toHaveBeenCalledTimes(2) + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(seen.at(-1)?.timeline.turns.has(1)).toBe(true) + expect(assembler.grouped('group-test')!.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + it('registers grouping without creating its Builder and disposes it with its effect', async () => { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) From b9252673e9cecc19463416d3d28a783d64b3b33f Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 13:18:42 +0800 Subject: [PATCH 26/36] fix(client): report only changed turn locations --- .../src/client/conversation/location-index.ts | 2 +- .../conversation-assembler.client.spec.ts | 18 ++++++++++++++++++ .../conversation-group-store.client.spec.ts | 6 +++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index 2f6eb70d9b..839beedeaf 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -494,7 +494,7 @@ export class ConversationLocationIndex { ? [...this.timeline.turnOrder, turnNumber] : this.timeline.turnOrder this.timeline = { turnOrder, turns } - this.changedTurns.add(turnNumber) + if (turn !== previousTurn) this.changedTurns.add(turnNumber) const changed = new Set() for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 7b6441f217..340263e65d 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -172,6 +172,24 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + it('reports boundary changes only when the owning Turn location changes', () => { + const index = new ConversationLocationIndex() + const boundaries = [ + at(SessionSeq(1), 'turn/start', { turn: 1 }), + at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }), + at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }), + at(SessionSeq(4), 'turn/end', { turn: 1 }), + ] + for (const boundary of boundaries) { + index.appendBoundary(boundary) + expect(index.takeChangedTurns()).toEqual([1]) + const turn = index.snapshot().turns.get(1) + index.appendBoundary(boundary) + expect(index.snapshot().turns.get(1)).toBe(turn) + expect(index.takeChangedTurns()).toEqual([]) + } + }) + it('publishes Location data through stable per-key sources', () => { const index = new ConversationLocationIndex() const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 }) diff --git a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts index 2a165bfb93..a437f25b27 100644 --- a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts @@ -124,7 +124,7 @@ describe('ConversationGroupStore', () => { const initial = group('a', [node('a')]) const entries = [reference('a'), node('b')] replace(store, entries, [initial]) - expect(() =>{ store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) + expect(() => { store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) .toThrow('missing Node') expect(store.entries).toBe(entries) expect(store.groupSource(key('a')).getSnapshot()).toBe(initial) @@ -139,7 +139,7 @@ describe('ConversationGroupStore', () => { const store = new ConversationGroupStore() replace(store, [node('before')], []) const root = store.entries - expect(() =>{ replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') + expect(() => { replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') expect(store.entries).toBe(root) replace(store, [reference('a')], [group('a', [first])]) expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first]) @@ -163,7 +163,7 @@ describe('ConversationGroupStore', () => { store.publish() const root = store.entries const source = store.groupSource(key('a')) - expect(() =>{ store.prepareAndInstall(update, readNode) }).toThrow(error) + expect(() => { store.prepareAndInstall(update, readNode) }).toThrow(error) expect(store.entries).toBe(root) expect(source.getSnapshot()).toBe(initial) }) From 7dd6d1fc31229deb00b6fc2ae97b95dc3a4f97aa Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 13:29:46 +0800 Subject: [PATCH 27/36] docs(client): mark the grouping foundation implemented --- ...-09-21-conversation-build-groups.i18n.yaml | 6 ++ .../2026-09-21-conversation-build-groups.md | 66 +++++-------------- ...2026-09-21-conversation-build-groups.zh.md | 66 +++++-------------- ...sentation-policy-and-step-groups.i18n.yaml | 4 +- ...hat-presentation-policy-and-step-groups.md | 2 +- ...-presentation-policy-and-step-groups.zh.md | 2 +- ...-09-21-conversation-build-groups.i18n.yaml | 6 -- ...9-20-chat-work-details-migration.i18n.yaml | 4 +- .../2026-09-20-chat-work-details-migration.md | 38 ++++++++++- ...26-09-20-chat-work-details-migration.zh.md | 38 ++++++++++- 10 files changed, 112 insertions(+), 120 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-09-21-conversation-build-groups.md (72%) rename .agents/notes/{proposed => implemented}/architecture/2026-09-21-conversation-build-groups.zh.md (72%) delete mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml new file mode 100644 index 0000000000..282f61cdcb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -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: ab55de55767cca1da63d4c52a6b1defba1244ad3 +2026-09-21-conversation-build-groups.zh.md: ebef2df31b623125b8aa96ae4a42c5d6a97ec29b diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md similarity index 72% rename from .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md rename to .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index 9d7c233346..ab55de5576 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -1,6 +1,6 @@ # Agent Note: Definition-owned grouping with generic Node/Group assembly -Status: proposed +Status: implemented English | [中文](2026-09-21-conversation-build-groups.zh.md) @@ -12,11 +12,11 @@ A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat proc 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. -## Proposal +## Decision -The foundation implements 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 remain a separate business phase; this note stays proposed until that phase is complete. 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. +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. -### Agreed responsibilities +### Responsibilities | Requirement | Consequence | |---|---| @@ -32,9 +32,9 @@ The foundation implements a separate, Node-input `ConversationGroupDefinition`, | Record | Relationship | |---|---| -| [Business Node assembly](../../implemented/architecture/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](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](../feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | +| [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. @@ -59,7 +59,7 @@ These records retain independent rationale and remain active. Cross-View navigat | Update | Meaning | |---|---| -| Omit `entries` | Keep root order. | +| 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. | @@ -108,42 +108,15 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin | [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. | -| Business phase: process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | -| Business phase: AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | - -## Business phase: segmentation and incremental work - -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. ## 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, while its future header independently selects data. 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. +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; modes change header visibility, body visibility, and styles only. +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. -| 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 the business phase. - ## 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. @@ -164,22 +137,13 @@ This assumes the outer Turn process is open. Expanded retains whole-Turn folding **Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive. -## Acceptance criteria +## Verification -1. Builder, assembler, and GroupStore contain no Chat segmentation/category rules or concrete grouping class construction. -2. Group State owns membership, segmentation, summaries, and business invalidation. -3. Ungrouped targets retain their path; duplicate registration and missing input fail without eager Builder construction. -4. Group input carries final projected Nodes, previous/current values, stable order, and lifecycle-only Turn changes. -5. Data-only upserts preserve entries and members, avoid unrelated scans, and notify only changed group sources. -6. All related Nodes, Groups, and Location data are installed before publication; invalid or overlapping references fail before Group installation. -7. First activation, append, settlement, replacement, prepend, and registry reconstruction use the common grouping path. -8. Registration replacement/removal leaves no old Group context or observed output; replacement Node stores rebind mounted members. -9. React renders only node/group root kinds and retains keys and parents across modes; local state survives mode changes. -10. Data types remain correlated through Definition, store, keyed reading, and business rendering. -11. Empty versus omitted entries, Group removal versus Node retention, and undefined removed-group sources retain their documented distinctions. -12. The separate business phase covers replies, steering, retries, no-Turn rows, Turn closure, summaries, and all three modes before claiming PR #4565 behavior complete. +- [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. +- [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. -## Risks +## 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. The Definition must distinguish content growth from structural changes and index the affected ranges. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md similarity index 72% rename from .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md rename to .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index 6736a65e24..ebef2df31b 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -1,6 +1,6 @@ # Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出 -Status: proposed +Status: implemented [English](2026-09-21-conversation-build-groups.md) | 中文 @@ -12,11 +12,11 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。 -## Proposal +## Decision -基础阶段实现独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。真实 Chat 分段及 PR #4565 展示仍属于独立业务阶段;该阶段完成前,本记录保留 proposed 状态。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。 +分组基础机制提供独立的节点输入 `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 细节。 -### 已确认的职责 +### 职责 | 要求 | 含义 | |---|---| @@ -32,9 +32,9 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | 记录 | 关系 | |---|---| -| [业务节点组装](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | -| [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | -| [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | +| [业务节点组装](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 或资源导航架构。 @@ -59,7 +59,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | 更新 | 含义 | |---|---| -| 省略 `entries` | 保留根顺序。 | +| apply 输入省略 `entries` | 保留根顺序;替换输入必须提供完整序列。 | | 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 | | `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 | | `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 | @@ -108,42 +108,15 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 | [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 引擎。 | -| 业务阶段:process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | -| 业务阶段:AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | - -## 业务阶段:分段与增量 - -业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 - -| 有序可见输入 | 业务动作 | -|---|---| -| 所属 Turn 改变 | 关闭待完成组。 | -| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | -| turn-process 控件 | 独立输出,不关闭待完成过程。 | -| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | -| Assistant 推理 | 把 reasoning 部分加入待完成组。 | -| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | -| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | -| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | - -分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 ## React 读取与展示 -根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members,后续组头独立选择 data。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 +根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 -React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 +React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。 稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。 -| 模式 | 组头 | 正文 | -|---|---|---| -| Compact | 简短活动摘要 | 按组开合状态显隐。 | -| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | -| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | - -以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于业务阶段。 - ## Alternatives considered **Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。 @@ -164,22 +137,13 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 **Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。 -## Acceptance criteria +## Verification -1. Builder、assembler 和 GroupStore 不含 Chat 分段或类别规则,不固定创建具体分组类。 -2. Group State 拥有成员、分段、摘要和业务失效规则。 -3. 未分组目标保持原路径;重复注册及缺少输入报错,不提前构造 Builder。 -4. 分组输入包含投影后的最终节点、前后值、稳定顺序及仅生命周期的轮次变化。 -5. 仅数据 upsert 保留 entries 和 members,不扫描无关内容,只通知变化组来源。 -6. 相关 Node、Group 和 Location 数据先全部安装再发布;无效及重叠引用在 Group 安装前报错。 -7. 首次激活、append、settlement、replace、prepend 和注册重建使用统一分组路径。 -8. 注册替换或删除不遗留旧 Group 上下文及观察结果;Node 存储替换时重绑定已挂载成员。 -9. React 根层只有 node/group,模式切换保留 key、父级及局部状态。 -10. Definition、存储、按键读取及业务渲染贯穿同一 Data 类型。 -11. 空与省略 entries、删除 Group 与保留 Node、已删除组返回 undefined 均保持协议规定的区别。 -12. 独立业务阶段覆盖回复、steering、重试、无 Turn 行、Turn 结束、摘要和三档展示后,才能宣称 PR #4565 产品行为完成。 +- [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 数据来源。 +- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。 -## Risks +## Consequences - 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 - 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml index e6ea7582a7..2c52ae61da 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -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: 3057403c8c9055f4fbc631ba0b4ca0bae2fcd2de -2026-09-20-chat-presentation-policy-and-step-groups.zh.md: f5b30bf96e9ca78b1f9eaacee1a62dd7cb3ec3da +2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md index 3057403c8c..65fbb313d5 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -25,7 +25,7 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit ## Goals and constraints -The [Definition-owned Build Group proposal](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 [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. diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index f5b30bf96e..7ac3442476 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -25,7 +25,7 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不 ## 目标与约束 -[Definition 拥有分组的 Build Group 提案](2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 +[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml deleted file mode 100644 index a11e052d9a..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/architecture/2026-09-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: 9d7c233346e04218821719b43cfa8abc21651854 -2026-09-21-conversation-build-groups.zh.md: 6736a65e24b99cb2b3bd3d3bd20cd93a92bb63aa diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml index 1f41262d74..25c2c8df38 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md index cd5dbe7234..126b61d32d 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -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 | diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md index 4055b027fc..20727c2993 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -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 揭示、选择复制及中断提示归属。 | + ## 性能约束 | 场景 | 要保留的性质 | 所需证据 | From 440070e4f8dfb1917c1a0a83385c6a4a52e266f6 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Mon, 21 Sep 2026 13:51:53 +0800 Subject: [PATCH 28/36] fix(web): address diff preview review findings --- apps/web/tests/changed-files-turn.e2e.ts | 16 ++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/ChangedFiles.module.css | 5 +- .../src/client/ChangedFiles.tsx | 11 ++-- .../src/client/Deliverables.tsx | 2 +- .../src/client/FileDiff.module.css | 28 ++++++++++ .../ui-deliverables/src/client/FileDiff.tsx | 4 +- .../src/client/ReviewTab.module.css | 30 +---------- .../ui-deliverables/src/client/ReviewTab.tsx | 9 ++-- .../tests/changed-files-hover.client.spec.tsx | 14 ++++- .../tests/deliverables.client.spec.tsx | 23 +++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/HoverCard.module.css | 1 - .../client/ui-primitives/src/HoverCard.tsx | 53 ++++++++++--------- .../tests/hover-card.client.spec.tsx | 39 ++++++++++++++ 19 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/FileDiff.module.css diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 6a5d394cc1..a3691df3c2 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -221,6 +221,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { 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 } @@ -249,11 +251,25 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { 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 () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e10d0408ba..84e8bd43ae 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 17b0d77b365a7198ff81dac4c51adddf94957859 -README.zh.md: 0399a355cb9ecee78ee992df3b6a614b9e9ceb86 +README.md: 7bf55c474250b8e389a1767092be0f82b4073222 +README.zh.md: d8b1fc368487d96091b400c6034d159989352192 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 17b0d77b36..7bf55c4742 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -38,7 +38,7 @@ The `present` tool row retains its document icon and recorded result across prog Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available. -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes and hunk headers are hidden in the preview. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it or pressing Escape dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes accompanying code and hunk headers are hidden in the preview; comparisons without hunks retain their status text. Each row exposes the full path as its accessible description. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it, pressing Escape, or activating the row by pointer or keyboard dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. ### The review tab diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 0399a355cb..d8b1fc3684 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -38,7 +38,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示文件状态说明和 hunk 头。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开,移出或按 Escape 关闭。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示代码旁的文件状态说明和 hunk 头;没有 hunk 的对比仍显示状态文字。每行通过无障碍描述提供完整路径。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开;移出、按 Escape 或通过鼠标和键盘激活文件行会关闭浮层。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 ### review tab diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 7ec998b044..0aa5f6558f 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -30,8 +30,7 @@ button.header:disabled { cursor: progress; } .toggle svg { flex: none; width: 14px; height: 14px; } @media (pointer: coarse) { .row, .toggle { min-height: 44px; } } -.preview { height: auto; max-height: 100%; overflow: hidden; } +.preview { max-height: 100%; overflow: hidden; } .previewPath { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; white-space: nowrap; } -.previewPath:focus-visible { outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } -.preview [data-diff-note], .preview [data-diff-hunk-header] { display: none; } +.preview [data-diff-note='metadata'], .preview [data-diff-hunk-header] { display: none; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 79b8bb6e48..5e06f5111c 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,12 +1,12 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { HoverCard, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { changesDiffUrl, type ChangesSummary } from '../changes.ts' import type { DeliverablesInjected } from './Deliverables.tsx' import { FileDiff } from './FileDiff.tsx' -import reviewCss from './ReviewTab.module.css' +import diffCss from './FileDiff.module.css' import { IconCodeBracketsOutline16 } from './icons.tsx' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' @@ -42,6 +42,7 @@ export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChange openReview: (index: number) => void } & PropsLocale & PreviewProps) { const cardRef = useRef(null) + const pathDescriptionId = useId() const [expanded, setExpanded] = useState(false) const foldable = changes.files.length > COLLAPSED_ROWS const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files @@ -66,6 +67,7 @@ export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChange display={resolveWorkspacePath(cwd, file.path)} useChangesDiff={useChangesDiff} loadChangesDiff={loadChangesDiff} t={t} />} anchor={} /> + ))} @@ -97,8 +100,8 @@ function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, lo useEffect(() => { if (state === undefined) void loadChangesDiff(sessionId, seq, index) }, [state, sessionId, seq, index, loadChangesDiff]) - return
-
{display}
+ return
+
{display}
{ void loadChangesDiff(sessionId, seq, index) }} />
} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 5bcdb34946..544e7a82b2 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -92,7 +92,7 @@ export function Deliverables({ if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) return <> - {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) return (
- {note !== undefined &&

{t(note)}

} + {note !== undefined &&

{t(note)}

} {diff.coarse &&

{t('diff.coarse')}

} {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} {split && !wrap ? : hunks.map((hunk, position) => { diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 8d9fd9ede4..28fb9b62f4 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,7 +1,5 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } -.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/** Review tab sizing, file selector, and file action controls. */ +.root { height: 100%; } .selector { flex: 0 1 auto; min-width: 0; } .selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } @@ -22,27 +20,3 @@ .tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); } .tool:disabled { cursor: progress; } .tool[data-error] { color: var(--dsw-alias-state-error-primary); } -.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } -.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } -.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; } -.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } -.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } -.hunk { margin-bottom: 8px; } -.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; } -.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; } -.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } -.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; } -.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; } -.sign { text-align: center; user-select: none; } -.text { padding-right: 16px; } -.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } -.body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } -.context .text { color: var(--dsw-alias-label-secondary); } -.empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 158b2495f5..9574dd7bdf 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -21,6 +21,7 @@ import type { ChangesReviewParams } from './review-definition.ts' import type { createReviewStore } from './review-store.ts' import type { NS } from './locales.ts' import { FileDiff } from './FileDiff.tsx' +import diffCss from './FileDiff.module.css' import css from './ReviewTab.module.css' const GROUPED = new Intl.NumberFormat('en-US') @@ -108,8 +109,8 @@ export function ReviewTab({ const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable' const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready' return ( -
-
+
+
{file === undefined ? {t('review.title', { turn: String(coordinates.turn) })} : { setMenuOpen(false) }} @@ -150,8 +151,8 @@ export function ReviewTab({ })}
- {summaryState === 'loading' &&

{t('diff.loading')}

} - {summaryState === 'missing' &&

{t('diff.missing')}

} + {summaryState === 'loading' &&

{t('diff.loading')}

} + {summaryState === 'missing' &&

{t('diff.missing')}

} {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />}
diff --git a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx index a78c38cbcb..d0fbfe80d9 100644 --- a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx @@ -48,6 +48,8 @@ it('reads only after 500ms and renders the selected comparison in a single colum expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1') expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2') expect(preview.textContent).toContain('/workspace/main.ts') + expect(screen.getByRole('button', { name: 'View changes to main.ts', description: '/workspace/main.ts' })).toBe(row) + expect(preview.querySelector('[data-changes-preview-path]')?.getAttribute('tabindex')).toBeNull() fireEvent.pointerLeave(row) fireEvent.pointerEnter(preview) act(() => { vi.advanceTimersByTime(500) }) @@ -62,6 +64,16 @@ it('reads only after 500ms and renders the selected comparison in a single colum expect(openReview).toHaveBeenCalledExactlyOnceWith(0) }) +it('keeps a status note for a comparison with no hunks', () => { + const { row, diffs } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, hunks: [], + } }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview] [data-diff-note="empty"]')?.textContent).toBe('Both sides hold the same lines') +}) + it('cancels a short hover and the pending dwell when unmounted', () => { const { row, view, loadChangesDiff } = mount() fireEvent.pointerEnter(row) @@ -87,7 +99,7 @@ it('previews each row independently, retries failed reads, and closes on Escape' act(() => { vi.advanceTimersByTime(200) }) act(() => { vi.advanceTimersByTime(300) }) expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1) - expect(screen.getByText('/workspace/second.ts')).toBeTruthy() + expect(document.querySelector('[data-changes-preview-path]')?.textContent).toBe('/workspace/second.ts') fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' }) act(() => { vi.advanceTimersByTime(100) }) expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 4756af65c6..2231effb3d 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -602,9 +602,9 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('-326')).toBeTruthy() expect(within(card).getByText('Preview in sidebar')).toBeTruthy() expect(within(card).getAllByRole('listitem')).toHaveLength(4) - expect(within(card).getByText('config/design-token')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to config/design-token' })).getByText('config/design-token')).toBeTruthy() expect(within(card).getByText('+42')).toBeTruthy() - expect(within(card).getByText('src/index.ts')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to src/index.ts' })).getByText('src/index.ts')).toBeTruthy() expect(within(card).queryByText('~/.zshrc')).toBeNull() expect(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' }) .querySelector('svg')?.getAttribute('width')).toBe('10') @@ -621,6 +621,7 @@ describe('ChangedFiles card', () => { expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull() + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc', description: '/home/u/.zshrc' })).toBeTruthy() fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) @@ -648,6 +649,19 @@ describe('ChangedFiles card', () => { expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') }) + it('preserves expanded rows when the served announcement changes', () => { + const summaries = servedStore() + summaries.state.set({ ...summaries.state.getSnapshot(), [changesSummaryUrl(SessionId('child-session'), 6)]: served }) + const { props, openFile, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] }, summaries) + const card = view.container.querySelector('[data-changed-files]') + fireEvent.click(view.getByRole('button', { name: 'Show all 5 changed files' })) + view.rerender() + expect(view.container.querySelector('[data-changed-files]')).toBe(card) + expect(view.getAllByRole('listitem')).toHaveLength(5) + expect(view.getByRole('button', { name: 'Collapse changed files' }).getAttribute('aria-expanded')).toBe('true') + }) + it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ @@ -794,7 +808,12 @@ describe('plugin registration', () => { await tabFace.loadChangesSummary(SessionId('child-session'), 6) expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) + await face.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(face.hooks.changesDiff).toBe(tabFace.hooks.changesDiff) + expect(face.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + const readsAfterHover = fetcher.mock.calls.length await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(fetcher).toHaveBeenCalledTimes(readsAfterHover) expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 499ec1d975..cfdfbb6a39 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: f639551b1ff008e226a9e9fc42784160c87b41cd -README.zh.md: 8054de52d2bd1dac6a2550e94906f4a15c269a72 +README.md: df0a8984a187473fdc2f76947a83c282dc8744ce +README.zh.md: 468719ee165fc39fae2f6d1d1524c1099b1e8a13 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f639551b1f..df0a8984a1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,7 +72,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel caps its height at 420px and follows content and anchor resizing; Escape dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8054de52d2..468719ee16 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 551b25c89a..e23a27e11e 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -56,7 +56,6 @@ .preview { display: flex; flex-direction: column; - max-height: 420px; padding: 0; overflow: hidden; border: 0; diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index b08e5dd5ec..b0c8ae86f8 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,13 +1,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode, RefObject } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' +import { overlayTopMargin } from './overlay-top-margin.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ const PREVIEW_FADE_MS = 100 +const PREVIEW_MAX_HEIGHT = 420 +const PREVIEW_INSET = 24 +const ANCHOR_GAP = 8 +const VIEWPORT_MARGIN = 8 /** * Render an anchor with a hover-triggered preview card. @@ -16,7 +21,7 @@ const PREVIEW_FADE_MS = 100 * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and @@ -129,20 +134,22 @@ export function HoverCard({ const h = cardRef.current?.offsetHeight ?? 0 if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r - const width = Math.max(0, Math.min(bounds.width - 48, window.innerWidth - 16)) - const above = Math.max(0, r.top - 16) - const below = Math.max(0, window.innerHeight - r.bottom - 16) - const onTop = above >= Math.min(420, below) - const maxHeight = Math.min(420, onTop ? above : below) + const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) + const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) + const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) + const onTop = above >= Math.min(PREVIEW_MAX_HEIGHT, below) + const maxHeight = Math.min(PREVIEW_MAX_HEIGHT, onTop ? above : below) setPos({ - left: Math.max(8, Math.min(bounds.left + 24, window.innerWidth - width - 8)), - top: onTop ? Math.max(8, r.top - Math.min(h, maxHeight) - 8) : r.bottom + 8, + left: Math.max(VIEWPORT_MARGIN, Math.min(bounds.left + PREVIEW_INSET, window.innerWidth - width - VIEWPORT_MARGIN)), + top: onTop ? Math.max(topMargin, r.top - Math.min(h, maxHeight) - ANCHOR_GAP) : belowTop, width, maxHeight, }) return } - const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top - setPos({ left: r.right + 8, top }) + const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top + setPos({ left: r.right + ANCHOR_GAP, top }) } place() const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null @@ -165,8 +172,8 @@ export function HoverCard({ if (!open || pos === null || variant === 'preview') return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 - if (pos.top + h > window.innerHeight - 8) { - setPos({ left: pos.left, top: window.innerHeight - h - 8 }) + if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { + setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } }, [open, pos, variant]) @@ -185,6 +192,13 @@ export function HoverCard({ } const copyable = copyText !== undefined + const dismissFromAnchor = (event: SyntheticEvent): void => { + // Portal content is a React child, but its clicks and text selection do not activate the anchor. + if (cardRef.current?.contains(event.target as Node)) return + clearTimer() + cancelClose() + close() + } const card = open && pos !== null && (
{ - if (cardRef.current?.contains(e.target as Node)) return - clearTimer() - cancelClose() - close() - }} + onPointerDownCapture={dismissFromAnchor} + onClickCapture={dismissFromAnchor} > {anchor} {open && copyable && {copied ? copiedLabel : ''}} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 7a2195949f..a71922c828 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -158,6 +158,45 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { + const { anchor, wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.click(anchor, { detail: 0 }) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByText('card body')) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(false) + fireEvent.click(anchor, { detail: 0 }) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps previews below the desktop frame clearance', () => { + const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance') + const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance') + document.documentElement.style.setProperty('--dsh-frame-top-clearance', '120px') + try { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 420 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 450, width: 600, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('120px') + expect(card.style.maxHeight).toBe('322px') + } finally { + if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance') + else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority) + } + }) + it('pointerleave before the delay cancels the pending open', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) From ad9f77db04a1edeb4e7eb91a95c4e08a3f30a2e3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 14:28:15 +0800 Subject: [PATCH 29/36] fix(preview): compact spreadsheet formula warning --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +-- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 32 ++++++++++++++++--- .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/excel/ExcelBody.module.css | 30 +++++++++++++---- .../src/client/excel/excel.tsx | 13 +++++--- .../src/client/excel/locales.ts | 12 ++----- .../tests/excel-body.client.spec.tsx | 20 ++++++------ .../tests/excel-convert.client.spec.ts | 15 +++++++-- .../tests/excel-fixture.ts | 28 ++++++++++++++++ .../web/document-preview/document.expected.md | 2 ++ 14 files changed, 122 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 2c6432adcf..f279a70f08 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: c31c563664d9c888a85d8b7fcb7f3abb50422f44 -2026-09-16-browser-excel-preview.zh.md: 09e24a28a76d00e60357b1c1acacdf82943fe567 +2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435 +2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index c31c563664..5631906931 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -12,7 +12,7 @@ PDF conversion loses spreadsheet navigation and the relationship between a formu 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 with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. +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. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 09e24a28a7..4107b98624 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -12,7 +12,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 [文档预览](../../../../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 渲染引擎。 +预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空。包含公式的工作簿会在公式图标旁显示紧凑提示,不再用整行提示占用工作表高度。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index ec09035d09..ad27f80c82 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,7 +9,7 @@ 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 } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-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' @@ -246,6 +246,7 @@ else process.exit(1); ...[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'), @@ -764,17 +765,37 @@ else process.exit(1); 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 excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) - await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') - expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') + 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') @@ -786,7 +807,6 @@ else process.exit(1); await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') await successShot(page, 'excel-legacy') await openFile('literal-html.xlsx') - const formulaInput = excel.locator('.fortune-fx-input') for (const [sheet, formula, copied] of [ ['Formula', `="${excelHtmlText}"`, 'saved result'], ['Text', excelHtmlText, excelHtmlText], @@ -812,6 +832,8 @@ else process.exit(1); '## 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', diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 767eda39c1..1fae2bc487 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: 7e9650c3e65763dfe5536077e19adffd7cc70e02 -README.zh.md: d391c95f6b942596c5665022448189d6a70aa2b1 +README.md: 67136b8e5ff81dee26905be7734c981f18fc3b0a +README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 7e9650c3e6..67136b8e5f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results receive a notice. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service. CSV and TSV default to the spreadsheet viewer and also offer Plain text. Commas and tabs delimit their fields respectively; quoted separators, escaped quotes, multiline fields, empty fields, and unequal row lengths are supported. The first row remains data. Values remain literal strings, including leading zeros, dates, booleans, and formula-looking text. Text files accept UTF-8 or BOM-marked UTF-16; invalid encoding receives conversion guidance. Malformed quoted fields fail the table preview rather than silently dropping data. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index d391c95f6b..9686863b9d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。 CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index 25d00bc7eb..efd00a9c2a 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -9,19 +9,35 @@ } .workbook { + position: relative; flex: 1; min-height: 0; min-width: 0; color-scheme: light; } -.notice { - flex: none; - margin: 0; - padding: 6px 12px; - font-size: 12px; - color: var(--dsw-alias-label-secondary); - border-bottom: 0.5px solid var(--dsw-alias-border-l3); +.formulaWarning { + position: absolute; + top: 4px; + left: 142px; + z-index: 3; + display: flex; + width: 20px; + height: 20px; + padding: 3px; + color: var(--dsw-alias-state-warn-label); + background: transparent; + border: 0; + border-radius: 4px; +} + +.formulaWarning:hover, .formulaWarning:focus-visible { + background: var(--dsw-alias-state-warn-tertiary); + outline: none; +} + +.withFormulaWarning :global(.fortune-fx-input) { + padding-left: 24px; } .status { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index 2aaa7ad200..b2e94fdea7 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -1,7 +1,7 @@ /** Read-only spreadsheet surface backed by browser-parsed workbook data. */ import { useEffect, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' -import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconLoadingOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' import type { ExcelFormat } from './format.ts' import type { ExcelPreview } from './model.ts' @@ -19,7 +19,6 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` */ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined - const delimited = format === 'csv' || format === 'tsv' const [state, setState] = useState() const [attempt, setAttempt] = useState(0) useEffect(() => { @@ -40,11 +39,15 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): {t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}
+ const hasFormulas = state.value.sheets.some(sheet => sheet.celldata?.some(cell => cell.v?.f !== undefined)) return
-

{t(delimited ? 'textSummary' : 'summary')}

- {state.value.missingResults > 0 &&

{t('missingResults')}

} -
+
+ {hasFormulas && + + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index cdcde81ed3..42c2b1f066 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -4,11 +4,7 @@ export const zh = { invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', - summary: '只读预览 · 显示已保存的公式结果', - textSummary: '只读预览 · 按原始文本显示单元格', - xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。', - limitations: '图表、图片、数据透视表和条件格式暂不显示。', - missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', + formulaWarning: '此工作簿包含公式,显示结果可能缺失或不准确。', retry: '重试', } satisfies Record @@ -21,11 +17,7 @@ export const en = { invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', - summary: 'Read-only preview · Saved formula results', - textSummary: 'Read-only preview · Literal cell text', - xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.', - limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', - missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', + formulaWarning: 'This workbook contains formulas. Displayed results may be missing or inaccurate.', retry: 'Retry', } satisfies Record diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx index fa3bf9aaf2..ab9e905cf1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -16,15 +16,16 @@ import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } +const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1 } afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows loading then a workbook with editing and recalculation disabled', async () => { - mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) + mocked.parse.mockResolvedValue(formulaValue) const view = render() expect(screen.getByRole('status', { name: en.loading })).toBeDefined() - await screen.findByText(en.summary) - expect(screen.getByText(en.missingResults)).toBeDefined() - expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.getByRole('button', { name: en.formulaWarning })).toBeDefined() + expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: formulaValue.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.unmount() expect(signal.aborted).toBe(true) @@ -36,10 +37,10 @@ it('ignores a retired file result and cancels parsing when the bytes change', as const view = render() const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.rerender() - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) expect(signal.aborted).toBe(true) - finish({ ...value, missingResults: 1 }) - await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) + finish(value) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) }) it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { @@ -48,7 +49,7 @@ it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows loc const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid' await screen.findByText(en[key]) fireEvent.click(screen.getByRole('button', { name: en.retry })) - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) expect(mocked.parse).toHaveBeenCalledTimes(2) }) @@ -61,7 +62,8 @@ it('rejects non-byte content without allocating a parser', () => { it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => { mocked.parse.mockResolvedValue(value) render() - await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.queryByRole('button', { name: en.formulaWarning })).toBeNull() expect(mocked.parse.mock.calls[0]![1]).toBe(format) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 31a1b35c58..edb37f7e9f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -4,12 +4,12 @@ import { strToU8, unzipSync, zipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { convertExcel } from '../src/client/excel/convert.ts' import { Config } from '../src/config.ts' -import { excelFixture } from './excel-fixture.ts' +import { excelFixture, meetingMinutesFixture } from './excel-fixture.ts' const limits = Config({}).excel describe('Excel conversion', () => { - it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { + it('keeps values, dates, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { const input = await excelFixture() const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits) expect(input.byteLength).toBeGreaterThan(0) @@ -42,6 +42,17 @@ describe('Excel conversion', () => { expect(missingResults).toBe(1) }) + it('opens workbooks with missing results and Chinese worksheet references', async () => { + const { sheets, missingResults } = await convertExcel(await meetingMinutesFixture(), 'xlsx', limits) + expect(sheets.map(sheet => sheet.name)).toEqual(['会议信息', '会议议程', '决议事项', '行动计划', '待确认问题', '风险与依赖', '统计看板', '填写说明']) + const dashboard = sheets[6]! + const cell = (r: number, c: number) => dashboard.celldata!.find(value => value.r === r && value.c === c)!.v + expect(cell(4, 1)).toMatchObject({ f: '=COUNTIF(行动计划!$G$4:$G$23,$A5)', m: '' }) + expect(cell(4, 2)).toMatchObject({ f: '=IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))', m: '' }) + expect(cell(4, 3)).toMatchObject({ f: '=IFERROR($B5/$B$10,0)', m: '' }) + expect(missingResults).toBe(27) + }) + it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) const input = await excelFixture() diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index ef9eefc42b..dcfc93abce 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -62,3 +62,31 @@ export async function excelHtmlFixture(): Promise> { workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText } return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Build a meeting-minutes workbook whose formulas have no cached results. */ +export async function meetingMinutesFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + const info = workbook.addWorksheet('会议信息') + info.getCell('A1').value = '会议纪要' + info.getCell('B11').value = { formula: 'IF(OR(B9="",B10=""),"",ROUND((B10-B9)*1440,0))' } + const agenda = workbook.addWorksheet('会议议程') + agenda.getCell('D16').value = { formula: 'SUM(D4:D15)' } + agenda.getCell('E16').value = { formula: 'SUM(E4:E15)' } + workbook.addWorksheet('决议事项') + const actions = workbook.addWorksheet('行动计划') + for (let row = 4; row <= 23; row += 1) { + actions.getCell(`I${row}`).value = { formula: `IF(AND($E${row}<>"",$E${row}"已完成",$G${row}<>"已取消"),TODAY()-$E${row},"")` } + } + workbook.addWorksheet('待确认问题') + workbook.addWorksheet('风险与依赖') + const dashboard = workbook.addWorksheet('统计看板') + dashboard.getCell('A1').value = '会议纪要 · 统计看板' + dashboard.getCell('A5').value = '未开始' + dashboard.getCell('B5').value = { formula: 'COUNTIF(行动计划!$G$4:$G$23,$A5)' } + dashboard.getCell('C5').value = { formula: 'IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))' } + dashboard.getCell('D5').value = { formula: 'IFERROR($B5/$B$10,0)' } + dashboard.getCell('D5').numFmt = '0%' + dashboard.getCell('B10').value = { formula: 'SUM(B5:B9)' } + workbook.addWorksheet('填写说明') + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 0ae42ec777..633a072fa7 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -76,6 +76,8 @@ - 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 From a67ad912378a9600660e106d0046b42263cbb4f9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 14:56:14 +0800 Subject: [PATCH 30/36] feat(client): expose indexed positions to group definitions --- ...-09-21-conversation-build-groups.i18n.yaml | 4 +- .../2026-09-21-conversation-build-groups.md | 10 +++-- ...2026-09-21-conversation-build-groups.zh.md | 10 +++-- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 5 ++- docs/subsystems/conversation.zh.md | 5 ++- .../chat-snapshot-builder.ts | 43 +++++++++++++++++-- .../tests/chat-node-source.client.spec.ts | 9 +++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/groups.ts | 15 ++++++- .../ui-conversation/src/client/index.ts | 2 +- .../tests/conversation-groups.client.spec.ts | 22 ++++++++-- 14 files changed, 109 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml index 282f61cdcb..d8c8fb934a 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -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-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: ab55de55767cca1da63d4c52a6b1defba1244ad3 -2026-09-21-conversation-build-groups.zh.md: ebef2df31b623125b8aa96ae4a42c5d6a97ec29b +2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602 +2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index ab55de5576..6591ebea61 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -46,9 +46,10 @@ These records retain independent rationale and remain active. Cross-View navigat - 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, synchronous `readNode`, and timeline; `apply` also supplies projected previous/current Nodes and `changedTurns`. The reader is valid during the synchronous call only and must not be retained in State. +- `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 @@ -80,7 +81,7 @@ GroupStore indexes records and sources by GroupKey, not repeated array searches. 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, and retains GroupInput without notifying independent sources. +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. @@ -103,7 +104,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin | [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 and stable order; defer source notification. No process grouping class. | +| [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. | @@ -141,12 +142,13 @@ The stable Group parent is a `div` with `display: contents`, without its own lay - [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. The Definition must distinguish content growth from structural changes and index the affected ranges. +- 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. diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index ebef2df31b..70657ded8e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -46,9 +46,10 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat - 注册要求已有 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` 和时间线;`apply` 还提供投影后的节点前后值及 `changedTurns`。读取函数仅在当前同步调用中有效,不保留到 State。 +- `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 引用与更新 @@ -80,7 +81,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。 3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。 4. flush 依次物化 Step、Turn Location 数据及目标 Node。 -5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,保留 GroupInput,不通知独立来源。 +5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。 6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。 7. 调用 buildGroups,校验并安装结果,安装目标快照。 8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。 @@ -103,7 +104,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 | [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) | 记录投影后节点增量及稳定顺序,推迟来源通知,不持有过程分组类。 | +| [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 替换时重绑定。 | @@ -141,12 +142,13 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - [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 必须区分正文增长与结构变化,并索引受影响范围。 +- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。 - Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index e6c640b8fa..56327598b7 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -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: 29ed4aa85ebdcc9061f33d620e26119fe7a9f927 -conversation.zh.md: 2aba5ab97dd93a5561e4790da87aa72939ebe322 +conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310 +conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 29ed4aa85e..9455858aca 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -37,7 +37,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou | Type | Meaning | |---|---| | `ConversationGroupDefinition` | `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` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | +| `ConversationGroupInput` | `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` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | | `GroupUpdate` | `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. | @@ -46,6 +47,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou 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. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 2aba5ab97d..2e7c48ab5f 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -37,7 +37,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro | 类型 | 含义 | |---|---| | `ConversationGroupDefinition` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 | -| `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | +| `ConversationGroupInput` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 | +| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 | | `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | | `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | | `GroupUpdate` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | @@ -46,6 +47,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro 注册分组时 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 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 7f5844ce2a..85b565a178 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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, ConversationGroupInput, NodeChange, NodeKey, 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() private steps = new Map() + private positions = new Map() 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() const steps = new Map() - for (const key of order) { + const positions = new Map() + const changedTurns = new Set() + 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. */ @@ -968,6 +993,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder 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 @@ -978,6 +1005,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder 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) @@ -1049,8 +1081,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { 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', @@ -49,13 +51,18 @@ describe('Chat Node keyed sources', () => { 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: [] }) + expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] }) expect(builder.groupInput().order).toBe(next.order) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 252ea7674a..60fd5ca07b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 41a9baeb7cc370cdb4a9d6702808646a0df38fb3 -README.zh.md: 1b5604f0c0c074ac64a28da61706be5d663fda85 +README.md: 6f115521a090ca32686af417a7ceea0634324c7d +README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 41a9baeb7c..6f115521a0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,7 +31,7 @@ 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 the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. 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. +`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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1b5604f0c0..babf6f5cfb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 -`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 +`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。 diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts index 37864617a8..e0fa2735e1 100644 --- a/packages/client/ui-conversation/src/client/contract/groups.ts +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -55,10 +55,21 @@ export interface NodeChange { 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 { 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 } @@ -69,6 +80,8 @@ export type ConversationGroupInput = GroupInputNodes & ( readonly kind: 'apply' readonly changes: readonly NodeChange[] readonly changedTurns: readonly number[] + /** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */ + readonly changedTurnOrders: readonly number[] } ) @@ -90,7 +103,7 @@ export interface ConversationGroupDefinition< /** * Consume target Node changes, not raw Session events or presentation modes. * @param context - current Session-local State. - * @param input - current target inputs; readNode is valid only during this call. + * @param input - current target inputs; its readers are valid only during this call. * @returns State adopted by the framework. */ update(context: ConversationGroupContext, input: ConversationGroupInput): State diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 998169610e..e776311040 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -7,7 +7,7 @@ export type { ConversationGroupRegistry } from './conversation/group-registry.ts export type { ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap, ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput, - GroupKey, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, + GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, NodeReference, RenderEntry, } from './contract/groups.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts index 78a95a6185..03be14383b 100644 --- a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -25,14 +25,27 @@ class GroupTestBuilder implements ConversationViewBuilder() readonly nodes = new Map() readonly publish = vi.fn() + private readonly readers = { + readNode: (key: NodeKey) => this.nodes.get(key), + readTurn: (_turn: number): readonly NodeKey[] => [], + readPosition: (key: NodeKey) => { + const index = this.input.order.indexOf(key) + if (index < 0) return undefined + return { + turn: undefined, + previous: this.input.order[index - 1], + next: this.input.order[index + 1], + } + }, + } private input: ConversationGroupInput = { - kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() }, + kind: 'replace', order: [], ...this.readers, timeline: { turnOrder: [], turns: new Map() }, } replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) { this.nodes.clear() for (const node of nodes) this.nodes.set(node.key, node) - this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline } return this.nodes } @@ -43,7 +56,10 @@ class GroupTestBuilder implements ConversationViewBuilder ({ previous: this.nodes.get(current.key), current })) for (const node of upserts) this.nodes.set(node.key, node) - this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + this.input = { + kind: 'apply', changes, changedTurns, changedTurnOrders: [], + order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline, + } return this.nodes } From 253fb5e66d1e3f5f2d2c807a9b796dae2b956757 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 16:27:19 +0800 Subject: [PATCH 31/36] fix(web): make Team tasks read-only and contain the panel --- ...-09-18-agent-teams-single-bundle.i18n.yaml | 4 +- .../2026-09-18-agent-teams-single-bundle.md | 2 +- ...2026-09-18-agent-teams-single-bundle.zh.md | 2 +- apps/web/tests/agent-team-panel.e2e.ts | 67 +- apps/web/tests/plugin-manager.e2e.ts | 5 +- .../agent-team-panel/task.expected.md | 15 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 16 +- docs/persistence-catalog.zh.md | 16 +- docs/persistence-schema.json | 8 +- docs/subsystems/agent-team.i18n.yaml | 4 +- docs/subsystems/agent-team.md | 16 - docs/subsystems/agent-team.zh.md | 16 - .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../experimental/agent-team/src/client.ts | 6 +- packages/experimental/agent-team/src/index.ts | 39 -- packages/experimental/agent-team/src/types.ts | 11 - .../agent-team/tests/built-lib.e2e.ts | 2 - .../agent-team/tests/team.spec.ts | 55 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 12 +- .../client-ui-agent-team/README.zh.md | 12 +- .../client-ui-agent-team/package.json | 1 + .../src/client/TeamAction.module.css | 90 +-- .../src/client/TeamAction.tsx | 338 ++-------- .../src/client/locales.ts | 24 - .../client-ui-agent-team/src/client/mount.ts | 12 +- .../tests/browser-plugin.client.spec.ts | 69 +- .../tests/team-action.client.spec.tsx | 608 ++---------------- .../extensions/tool-cordis/src/api-catalog.ts | 16 - pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 - 34 files changed, 242 insertions(+), 1244 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml index a005e6d029..ecf9b0571e 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md index 21878a8e48..b417061d24 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md index 39c6bfb7b3..ed20dac79f 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md @@ -30,4 +30,4 @@ Status: implemented ## 验证 -插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 +插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖只读任务看板;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index ac4e484db0..6d0d39b991 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -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,72 @@ 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 () => { + 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.click() + await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() + await panel.getByRole('button', { name: 'Refresh Team' }).focus() + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.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']) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 8ce5950d4c..1e7188510f 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -276,8 +276,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[] } } } diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 447a1f6858..5184f24d29 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -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" diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 60dba9fa9e..da386eec26 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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: dd52352ecff33c833154a5aa933767f0b2544b4a -persistence-catalog.zh.md: 71c0866cc2499ebaf573bc078ecb9f72a32ba128 +persistence-catalog.md: 45fe3d7549b98a1c199e3bba1565fbf2c11b3620 +persistence-catalog.zh.md: f55524fdf2f0c4dc3167a89249be8e703bfca7c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index dd52352ecf..45fe3d7549 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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) @@ -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) @@ -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) @@ -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/*` @@ -7373,7 +7373,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 | |---|---|---| @@ -7401,7 +7401,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 | |---|---|---| @@ -7415,7 +7415,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 | |---|---|---| @@ -7561,7 +7561,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 | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 71c0866cc2..f55524fdf2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[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) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[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) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[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) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[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/*` @@ -7375,7 +7375,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) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,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) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,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) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,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) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index c4e1c3b950..e525fb8391 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:227" + "packages/experimental/agent-team/src/types.ts:216" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:223" + "packages/experimental/agent-team/src/types.ts:212" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:221" + "packages/experimental/agent-team/src/types.ts:210" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:225" + "packages/experimental/agent-team/src/types.ts:214" ] }, { diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 6328a02541..6a04e1377d 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -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 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 653bbbcad5..2f49e16464 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -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 - -/** - * 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 ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index e9ef2d5dc8..24d7d73fda 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -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 - -/** - * 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 ``` Types: [Agent](core.zh.md) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 30fa8a2866..52019ce198 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -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/experimental/agent-team/README.md -README.md: 3829a2cae60aa4e666f20a35de7298b5703373b0 -README.zh.md: 9e2fe888fefd3b40a2780848630657df40dde37b +README.md: 6ae7d82214ca9e041324e353057ba6850675c043 +README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 3829a2cae6..6ae7d82214 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -173,7 +173,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser Remote -`TeamService` owns the generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside the roster, mailbox, task, and lifecycle operations. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` re-exports the request, view, and task-mutation result types that are safe in a browser compilation face. Typert retains transport failures in its outer `RemoteResult`; create and update rejections remain explicit domain results inside a successful transport response, with stale update revisions distinguished as task conflicts. +`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 9e2fe888fe..89176c285a 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -173,7 +173,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch ### 浏览器 Remote -`TeamService` 除了 roster、mailbox、task 与 lifecycle operation,还拥有生成的 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 则重新导出可在浏览器 compilation face 中安全使用的 request、view 与 task mutation result type。Typert 在外层 `RemoteResult` 中保留 transport failure;create 与 update rejection 则作为 transport 成功响应中的显式 domain result,其中过期的 update revision 会区分为 task conflict。 +`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index 122af45a0b..b1fd173d10 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,13 +1,9 @@ -/** Client-safe Agent Teams request, result, and view vocabulary. */ +/** Client-safe Agent Teams roster and task views. */ export type { - CreateTeamTaskRequest, TeamMemberView, - TeamTaskAction, TeamTaskId, - TeamTaskMutationResult, TeamTaskStatus, TeamTaskView, TeamView, - UpdateTeamTaskRequest, } from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index abdedc6d63..03ddbc7a48 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -23,7 +23,6 @@ import type { SpawnTeammateRequest, SpawnTeammateResult, TeamMemberView, - TeamTaskMutationResult, TeamTaskView, TeamView, TeamWaitResult, @@ -247,44 +246,6 @@ export class TeamService extends TypertRemoteService { } } - /** - * 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 { - return this.taskMutationResult(this.createTask(agent, request)) - } - - /** - * 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 { - return this.taskMutationResult(this.updateTask(agent, request)) - } - - /** Preserve Team task rejections while allowing unexpected failures to reject the Remote call. */ - private async taskMutationResult(operation: Promise): Promise { - try { - return { ok: true, value: await operation } - } catch (error) { - if (!(error instanceof TeamError)) throw error - return { - ok: false, - error: { - code: error.code === 'TEAM_TASK_STALE_REVISION' ? 'team-task-conflict' : 'team-rejected', - message: error.message, - }, - } - } - } - /** Queue one contained recovery pass after publication has unwound. */ private scheduleRecovery(agent: Agent): void { queueMicrotask(() => { diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index cf659ce9d0..77aa6fe620 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -199,17 +199,6 @@ export interface UpdateTeamTaskRequest { readonly owner?: string } -/** Browser task mutation result with stale revisions kept distinct from other Team rejections. */ -export type TeamTaskMutationResult = - | { readonly ok: true; readonly value: TeamTaskView } - | { - readonly ok: false - readonly error: { - readonly code: 'team-task-conflict' | 'team-rejected' - readonly message: string - } - } - /** Result of waiting for Team activity. */ export interface TeamWaitResult { readonly timedOut: boolean diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 5ae0619172..4e2d762fd6 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -40,8 +40,6 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { expect(output).toEqual({ className: 'TeamService', methods: [ - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/createTask', - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/updateTask', '@deepseek-ai/dsh-experimental-agent-team#agentTeams/view', ], }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 416df1fd8f..87d63353ed 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -874,63 +874,20 @@ describe('Team shared task DAG', () => { }) describe('Team Remote API', () => { - it('exports Team views and task mutations from the owning service', async () => { + it('reads tasks created and updated by Team agents', async () => { const { ctx, lead } = await setup([]) expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) expect(ctx.agentTeams.remoteView(lead)).toEqual({ members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], tasks: [], }) - - const createdResult = await ctx.agentTeams.remoteCreateTask(lead, { - subject: 'Remote task', - description: 'Created through the generated API', - blockedBy: [], - writeScopes: ['packages/experimental/agent-team'], + const created = await ctx.agentTeams.createTask(lead, { + subject: 'Agent task', description: 'Created by the Team Lead', }) - expect(createdResult).toMatchObject({ ok: true, value: { revision: 1 } }) - if (!createdResult.ok) throw new Error('Remote task creation did not succeed') - const created = createdResult.value - await expect(ctx.agentTeams.remoteUpdateTask(lead, { - taskId: created.id, - expectedRevision: created.revision, - action: 'claim', - })).resolves.toMatchObject({ - ok: true, - value: { id: created.id, revision: 2, ownerName: 'lead' }, + const updated = await ctx.agentTeams.updateTask(lead, { + taskId: created.id, expectedRevision: 1, action: 'claim', }) - expect(ctx.agentTeams.remoteView(lead).tasks).toHaveLength(1) - }) - - it('preserves Team task rejections and propagates unexpected failures', async () => { - const { ctx, lead } = await setup([]) - const createRequest = { - subject: 'Remote task', description: 'Rejected task', blockedBy: [], writeScopes: [], - } - const request = { taskId: TeamTaskId('task-1'), expectedRevision: 1, action: 'delete' as const } - vi.spyOn(ctx.agentTeams, 'createTask') - .mockRejectedValueOnce(new TeamError('invalid task', 'TEAM_TASK_INVALID')) - .mockRejectedValueOnce(new Error('unexpected creation failure')) - vi.spyOn(ctx.agentTeams, 'updateTask') - .mockRejectedValueOnce(new TeamError('stale', 'TEAM_TASK_STALE_REVISION')) - .mockRejectedValueOnce(new TeamError('denied', 'TEAM_TASK_FORBIDDEN')) - .mockRejectedValueOnce(new Error('unexpected mutation failure')) - - await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)).resolves.toEqual({ - ok: false, - error: { code: 'team-rejected', message: 'invalid task' }, - }) - await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)) - .rejects.toThrow('unexpected creation failure') - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ - ok: false, - error: { code: 'team-task-conflict', message: 'stale' }, - }) - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ - ok: false, - error: { code: 'team-rejected', message: 'denied' }, - }) - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).rejects.toThrow('unexpected mutation failure') + expect(ctx.agentTeams.remoteView(lead).tasks).toEqual([updated]) }) }) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 914a388b83..c5edd83d9c 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -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/experimental/client-ui-agent-team/README.md -README.md: 6d45e618d937cfadcec238c6d0d7dcf3e8747501 -README.zh.md: 879b1784bbe3d2ef6f3f66ab66b9dda7c820a0ed +README.md: 24eaa13a248980fcf683403e54fd6b138fdfcf27 +README.zh.md: 51ed00b31791e33d8cdbc57c2aa5491c127113e7 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 6d45e618d9..24eaa13a24 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -31,11 +31,11 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. -### Manage the task board +### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. A user can create, edit, assign or unassign, complete, reopen, and delete tasks through `agentTeams/createTask` and `agentTeams/updateTask`. Every update sends the displayed revision, and create or update rejections remain explicit business results. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. ----- @@ -47,7 +47,7 @@ The task board shows task identity, owner, blockers, readiness, advisory write s The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. -Starting a create or update invalidates older refreshes. Success reloads the complete Team view so every task's derived fields stay current. A `team-task-conflict` result displays a stale-state notice only after that reload succeeds; a reload failure remains visible instead. Editing task text or scopes and changing dependencies use two sequential compare-and-set mutations because the Team service exposes them as separate actions. +The panel renders outside the conversation container and stays within the viewport. Clicking outside or pressing Escape closes it. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. | File | Role | |---|---| @@ -73,7 +73,7 @@ Starting a create or update invalidates older refreshes. Success reloads the com ## Model Experience -None, as this browser projection and task control surface registers no model-facing input. +None, as this browser projection registers no model-facing input. #### KV Cache effect @@ -83,7 +83,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Snapshot refresh** — the panel refreshes on open, explicit refresh, and mutations; it has no live event subscription or mailbox timeline. +- **Snapshot refresh** — the panel refreshes on open and explicit refresh; it has no live event subscription or mailbox timeline. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 879b1784bb..51ed00b317 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -31,11 +31,11 @@ kind: "package-reference" 打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 -### 管理任务板 +### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -任务板展示 task identity、owner、blocker、readiness、提示性 write scope 与重叠 warning。用户可以通过 `agentTeams/createTask` 与 `agentTeams/updateTask` 创建、编辑、分配或取消分配、完成、重开和删除任务。每次 update 都发送当前显示的 revision,create 或 update rejection 都保留为显式 business result。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。 ----- @@ -47,7 +47,7 @@ kind: "package-reference" Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 -开始 create 或 update 会让更早的 refresh 失效。成功后会重新读取完整 Team view,使每个 task 的派生字段保持最新。`team-task-conflict` 结果仅在重新读取成功后显示状态陈旧提示;如果重新读取失败,则改为显示重新读取错误。由于 Team 服务把任务文本或 scope 编辑与 dependency 修改公开为独立 action,两者使用两个连续的 compare-and-set mutation。 +面板渲染在会话容器外,并保持在视口范围内。点击外部或按 Escape 会关闭面板。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 | 文件 | 职责 | |---|---| @@ -73,7 +73,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 模型体验 -无直接影响,因为该浏览器 projection 与任务控制界面不注册面向模型的输入。 +无直接影响,因为该浏览器 projection 不注册面向模型的输入。 #### KV Cache 影响 @@ -83,7 +83,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开、显式 refresh 与 mutation 后刷新;它没有实时事件订阅或 mailbox timeline。 +- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index c980e32c16..cbde881879 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index e8051dc387..29d35ccaf0 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -3,10 +3,7 @@ } .trigger, -.iconButton, -.smallButton, -.taskActions button, -.formActions button { +.iconButton { border: 0; border-radius: 6px; background: transparent; @@ -24,10 +21,7 @@ } .trigger:hover, -.iconButton:hover, -.smallButton:hover, -.taskActions button:hover, -.formActions button:hover { +.iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -43,13 +37,11 @@ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - position: absolute; - top: calc(100% + 5px); - left: 0; + position: fixed; z-index: 110; box-sizing: border-box; width: min(560px, calc(100vw - 32px)); - max-height: min(680px, calc(100vh - 120px)); + max-height: min(680px, calc(100vh - 32px)); padding: 10px; overflow: auto; border: 0; @@ -61,10 +53,7 @@ } .toolbar, -.sectionTitle, -.taskTitle, -.taskActions, -.formActions { +.taskTitle { display: flex; align-items: center; gap: 8px; @@ -135,23 +124,13 @@ color: var(--dsw-alias-state-error-primary); } -.smallButton { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: auto; - padding: 4px 7px; - font-size: 11px; -} - .tasks { display: flex; flex-direction: column; gap: 7px; } -.task, -.form { +.task { padding: 9px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 9px; @@ -180,63 +159,6 @@ gap: 4px 10px; } -.taskActions { - flex-wrap: wrap; - margin-top: 8px; -} - -.taskActions label { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; -} - -.taskActions select, -.form input, -.form textarea { - box-sizing: border-box; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 6px; - background: var(--dsw-alias-bg-base); - color: var(--dsw-alias-label-primary); - font: inherit; -} - -.taskActions button, -.formActions button { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 4px 6px; - font-size: 11px; -} - -.taskActions button:disabled, -.formActions button:disabled { - opacity: 0.45; - cursor: default; -} - -.form { - display: grid; - gap: 6px; - margin-bottom: 7px; -} - -.form input, -.form textarea { - width: 100%; - padding: 6px 8px; - font-size: 12px; -} - -.form textarea { - min-height: 58px; - resize: vertical; -} - .notice, .error { display: flex; diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index a32e2dc24e..0135f41a98 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,18 +1,15 @@ -import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberView as TeamRosterMember, - TeamTaskAction, - TeamTaskId, - TeamTaskMutationResult, TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { - IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconPlusOutlineRegular, - IconRefreshOutlineRegular, IconTrashOutlineRegular, IconUserOutlineRegular, StateDot, - type StateDotState, + IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, + useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -22,28 +19,9 @@ import css from './TeamAction.module.css' /** Generated Remote result consumed directly by the Team UI. */ export type TeamActionResult = RemoteResult -/** Generated Remote result whose business value preserves Team task rejections. */ -export type TeamTaskActionResult = RemoteResult - /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { load: (sessionId: SessionId) => Promise> - createTask: (sessionId: SessionId, input: { - subject: string - description: string - blockedBy: TeamTaskId[] - writeScopes: string[] - }) => Promise - updateTask: (sessionId: SessionId, input: { - taskId: TeamTaskId - expectedRevision: number - action: TeamTaskAction - subject?: string - description?: string - blockedBy?: TeamTaskId[] - writeScopes?: string[] - owner?: string - }) => Promise openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void } @@ -51,27 +29,6 @@ export interface TeamActionInjected { export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale -interface Draft { - subject: string - description: string - blockers: string - scopes: string -} - -const EMPTY_DRAFT: Draft = { subject: '', description: '', blockers: '', scopes: '' } - -function items(value: string): string[] { - return [...new Set(value.split(',').map(item => item.trim()).filter(Boolean))] -} - -function taskIds(value: string): TeamTaskId[] { - return items(value) as TeamTaskId[] -} - -/** - * One failure line for either carrier: a Remote failure, or a Team business - * rejection whose codes stay local to this seam and never ride the wire. - */ function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } @@ -114,19 +71,21 @@ function taskDotState(task: TeamTask): StateDotState { } } -/** Render the live Team roster and compare-and-set task board. */ +/** Render the Team roster and read-only task board. */ export function TeamAction({ - sessionId, load, createTask, updateTask, openTeammate, t, + sessionId, load, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [view, setView] = useState(null) const [error, setError] = useState(null) - const [creating, setCreating] = useState(false) - const [createDraft, setCreateDraft] = useState(EMPTY_DRAFT) - const [editing, setEditing] = useState(null) - const [editDraft, setEditDraft] = useState(EMPTY_DRAFT) - const [pendingTasks, setPendingTasks] = useState>(() => new Set()) + const rootRef = useRef(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const position = useAnchoredPosition({ + open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, + }) + useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const sessionRef = useRef(sessionId) const refreshGeneration = useRef(0) sessionRef.current = sessionId @@ -137,136 +96,37 @@ export function TeamAction({ setLoading(false) setView(null) setError(null) - setCreating(false) - setCreateDraft(EMPTY_DRAFT) - setEditing(null) - setEditDraft(EMPTY_DRAFT) - setPendingTasks(new Set()) }, [sessionId]) - const refresh = useCallback(async (): Promise => { + const refresh = useCallback(async (): Promise => { const requestedSession = sessionId const generation = ++refreshGeneration.current setLoading(true) const result = await load(requestedSession) - if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return false + if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return setLoading(false) if (result.ok) { setView(result.value) setError(null) - return true } else { setError(failureText(result.error)) - return false } }, [load, sessionId]) - const invalidateRefresh = useCallback((): void => { - refreshGeneration.current += 1 - setLoading(false) - }, []) - - const settleTask = useCallback(async ( - taskId: string, - operation: () => Promise, - ): Promise => { - const requestedSession = sessionId - invalidateRefresh() - setPendingTasks(current => new Set(current).add(taskId)) - try { - const result = await operation() - if (sessionRef.current !== requestedSession) return undefined - if (!result.ok) { - setError(failureText(result.error)) - return undefined - } - if (!result.value.ok) { - if (result.value.error.code === 'team-task-conflict') { - const reloaded = await refresh() - if (sessionRef.current !== requestedSession) return undefined - if (reloaded) setError(t('conflict')) - } else { - setError(failureText(result.value.error)) - } - return undefined - } - const task = result.value.value - setError(null) - await refresh() - if (sessionRef.current !== requestedSession) return undefined - return task - } finally { - if (sessionRef.current === requestedSession) { - setPendingTasks((current) => { - const next = new Set(current) - next.delete(taskId) - return next - }) - } - } - }, [invalidateRefresh, refresh, sessionId, t]) - - const submitCreate = async (): Promise => { - const subject = createDraft.subject.trim() - const description = createDraft.description.trim() - /* v8 ignore next -- TaskForm disables Save while either normalized field is empty. */ - if (subject === '' || description === '') return - const created = await settleTask('create', () => createTask(sessionId, { - subject, - description, - blockedBy: taskIds(createDraft.blockers), - writeScopes: items(createDraft.scopes), - })) - if (created === undefined) return - setCreateDraft(EMPTY_DRAFT) - setCreating(false) - } - - const startEdit = (task: TeamTask): void => { - setEditing(task.id) - setEditDraft({ - subject: task.subject, - description: task.description, - blockers: task.blockedBy.join(', '), - scopes: task.writeScopes.join(', '), - }) - } - - const submitEdit = async (task: TeamTask): Promise => { - const requestedSession = sessionId - const edited = await settleTask(task.id, () => updateTask(requestedSession, { - taskId: task.id, - expectedRevision: task.revision, - action: 'edit', - subject: editDraft.subject.trim(), - description: editDraft.description.trim(), - writeScopes: items(editDraft.scopes), - })) - if (edited === undefined) return - const blockedBy = taskIds(editDraft.blockers) - if (blockedBy.length === edited.blockedBy.length - && blockedBy.every((blocker, index) => blocker === edited.blockedBy[index])) { - setEditing(null) - return - } - const dependencyTask = await settleTask(task.id, () => updateTask(requestedSession, { - taskId: task.id, - expectedRevision: edited.revision, - action: 'set_dependencies', - blockedBy, - })) - if (dependencyTask === undefined) return - setEditing(null) - } - const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] - const assignable = view?.members.filter(member => member.status !== 'failed' && member.status !== 'provisioning') ?? [] return ( -
+
{ + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + }}> - {open && ( -
+ {open && createPortal( +
{t('trigger')} @@ -330,129 +197,36 @@ export function TeamAction({
-
-

{t('tasks')}

- -
- {creating && ( - { void submitCreate() }} - onCancel={() => { setCreating(false) }} - t={t} - /> - )} - {view.tasks.length === 0 && !creating &&
{t('empty')}
} +

{t('tasks')}

+ {view.tasks.length === 0 &&
{t('empty')}
}
- {view.tasks.map(task => editing === task.id - ? ( - { void submitEdit(task) }} - onCancel={() => { setEditing(null) }} - t={t} - /> - ) - : ( -
-
- {task.subject} - - - {t(statusKey(task.status))} - -
-

{task.description}

-
- {task.id} - {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} - {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} - {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} - {task.writeScopeWarnings.map(warning => {warning})} -
-
- - - {task.status === 'in_progress' && ( - - )} - {task.status === 'completed' && ( - - )} - -
-
- ))} + {view.tasks.map(task => ( +
+
+ {task.subject} + + + {t(statusKey(task.status))} + +
+

{task.description}

+
+ {task.id} + {t('owner')}: {task.ownerName ?? t('unowned')} + {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} + {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} + {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} + {task.writeScopeWarnings.map(warning => {warning})} +
+
+ ))}
)} -
+
, + document.body, )}
) } - -interface TaskFormProps { - draft: Draft - setDraft: (draft: Draft) => void - pending: boolean - onSave: () => void - onCancel: () => void - t: TeamActionProps['t'] -} - -function TaskForm({ draft, setDraft, pending, onSave, onCancel, t }: TaskFormProps) { - const field = (key: keyof Draft, value: string): void => { setDraft({ ...draft, [key]: value }) } - return ( -
- ) => { field('subject', event.target.value) }} /> -