refactor(office): 合并为 office-kit,Kit 缺失时降级而非加载失败

- Kit CLI 由 office-kit/locate 在 Host 中定位(含 ASAR 映射与可选 root 越界检查),删除 Desktop 壳的 office-cli 与诊断文件;生产版 Desktop 由壳通过 DSH_BUNDLED_RUNTIME_ROOT 显式传入 Kit 根目录,开发版与 Web 不检查
- office-to-pdf 与 office-kit-resolver 合并为 @deepseek-ai/dsh-office-kit,由同一包定位 Kit 并提供转换服务
- Kit 缺失时 provider 照常注册,转换请求以 OfficeKitError unavailable 拒绝,之后安装 Kit 无需重载
- 类型统一使用 OfficeKit 前缀(OfficeKitRequest/Result/Priority/Key),同步消费方、文档、升级指南与 catalog
- 处理 ds-review-bot 意见,并更新 unknown 断言基线

本 PR 的 snapshot 统一在这个提交里用 keyless refresh 重新生成,前 3 个提交中的 snapshot 不一定和那一步的代码对应。
This commit is contained in:
yudshj committed 2026-11-04 18:17:57 +08:00
1 parent 4a41d3ad1f
commit a220e017b5
160 files changed
+1853 -1654

No files matched your search

@@ -9,8 +9,8 @@
en: 2fc4ac7bdd4a3474
zh: 21ebdd3ac9264132
/agent-note-independent-libreoffice-kit-ownership/decision:
en: 0d8e05bcb940f88f
zh: b5b55f4ee82cf6a3
en: 2ecc1fd680d8ec31
zh: 1f5edeead8dceb44
/agent-note-independent-libreoffice-kit-ownership/alternatives-considered:
en: 55175bb30630bf0c
zh: 788853ddd5f7a4e9
@@ -10,11 +10,11 @@ LibreOffice compilation, source patches, platform qualification, and large binar
## Decision
The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeToPdf` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging.
The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeKit` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging.
Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages.
The upstream API declares platform engines as optional dependencies. The [platform engine reference](../../../../packages/document/office-to-pdf/README.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials.
The upstream API declares platform engines as optional dependencies. The [platform engine reference](../../../../packages/document/office-kit/README.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials.
The kit repository owns engine qualification and corresponding source materials. MPL-2.0 declarations, source availability, and redistribution notices accompany the API and engines; Harness retains these materials when packaging them. The notices check accepts the exact API, WASM, macOS ARM64/x64, and Windows ARM64/x64 package identities only at MPL-2.0; unrelated packages and changed license terms still reject.
@@ -10,11 +10,11 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布
## Decision
`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeToPdf` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。
`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeKit` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。
kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。
上游 API 将平台引擎声明为可选依赖。[平台引擎参考](../../../../packages/document/office-to-pdf/README.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。
上游 API 将平台引擎声明为可选依赖。[平台引擎参考](../../../../packages/document/office-kit/README.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。
kit 仓库负责引擎资格验证和对应源码材料。API 和引擎携带 MPL-2.0 声明、可访问源码及再分发声明;Harness 打包时保留这些材料。再分发声明检查仅在许可为 MPL-2.0 时接受 API、WASM、macOS ARM64/x64 和 Windows ARM64/x64 的精确包标识;无关包和改变后的许可条款仍被拒绝。
@@ -9,11 +9,11 @@
en: b305613daff5a763
zh: 1aba13289664f65c
/agent-note-bounded-shared-office-conversion/decision:
en: bcd0780f95f173bf
zh: 2a7f49db2575c59d
en: d3c8181e29c1ff3b
zh: a6f15b85e58907e5
/agent-note-bounded-shared-office-conversion/alternatives-considered:
en: 9d5cfb7fa5f66bd6
zh: 4f4133daa2f02f43
en: 73888970b0fc7f6b
zh: 9bcccf774116f849
/agent-note-bounded-shared-office-conversion/consequences:
en: c2800ff56c8f89d1
zh: 3b3710072b59c443
@@ -10,9 +10,9 @@ Office preview and explicit document inspection can request the same conversion.
## Decision
The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
The `office-kit` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
The [Host provider](../../../../packages/document/office-kit/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles.
@@ -28,7 +28,7 @@ Foreground preview and explicit QA requests precede background work. Disabling b
**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work.
**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-kit` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
## Consequences
@@ -10,9 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
## 决策
`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
`office-kit` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
[宿主提供方](../../../../packages/document/office-kit/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。
@@ -28,7 +28,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。
**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-kit` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
## 后果
@@ -9,8 +9,8 @@
en: a9984a594cdac98d
zh: 38768e7699bc8d39
/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision:
en: 814a218f1f4e839b
zh: 83a80a49a6580dd6
en: df88801241382a00
zh: cfc5f8b80420c09e
/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered:
en: 8bca955255e6d982
zh: bc313a4bcab3f6f9
@@ -11,11 +11,11 @@ Combined authoring and rendering instructions spend context on operations unrela
## Decision
The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. The Web application and SDK profiles mount the skills in every deployment; the shared base does not. Only Desktop, or an SDK with a primary runtime, directs Python work to the bundled workspace dependencies.
The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. [Office skills in every deployment](2026-10-26-cross-deployment-office-kit.md) supersedes this mounting decision.
Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management.
Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions.
LibreOffice Kit dependencies declare one exact version in every manifest; the root lock records the resolved adapter and engine versions.
## Alternatives considered
@@ -11,11 +11,11 @@ Status: implemented
## 决策
[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。Web 应用和 SDK profile 在所有部署中挂载这些技能;共享 base 不挂载。只有 Desktop 或带主运行时的 SDK 把 Python 工作导向内置的工作区依赖。
[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。[Office 技能在所有部署中可用](2026-10-26-cross-deployment-office-kit.zh.md)取代此挂载决策。
载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。
Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。
LibreOffice Kit 依赖在每个清单中声明同一个精确版本;根锁文件记录实际解析的适配器与引擎版本。
## 考虑过的替代方案
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-26-cross-deployment-office-kit.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.md
/agent-note-office-skills-in-every-deployment-with-one-office-kit-package:
en: aef0b589f3562990
zh: c2227dd19e282c6f
/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/problem:
en: 129d30508926bbc5
zh: 15feff44556fc9b7
/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/decision:
en: 9b8c5ac21c4322ec
zh: 6ccbba94ba1528d7
/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/alternatives-considered:
en: dbf6832a4ba0107d
zh: b707f4c3bef6a7bc
/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/consequences:
en: f5bbee7b5b166e63
zh: 5c9d28704356b88c
@@ -0,0 +1,41 @@
# Agent Note: Office skills in every deployment with one Office Kit package
Status: implemented
English | [中文](2026-10-26-cross-deployment-office-kit.zh.md)
## Problem
Office skills were mounted only by Desktop and the Python SDK, so ordinary `dsh web` users could not create or inspect Office documents with the same guidance. Each deployment located LibreOffice Kit differently: the conversion provider, the skills, and the Desktop shell each held their own lookup, and an absent Kit made the conversion row fail to load. A Python SDK executable without its downloaded Office sidecar, or a source checkout without native engines, is a deployment that lacks Kit, not a misconfigured composition; failing the row hid the provider and the skills even though most of their behavior did not need Kit.
## Decision
The [Web bundle](../../../../packages/bundle/web-app/cordis.patch.yml) and the [SDK bundle](../../../../packages/bundle/sdk-app/cordis.patch.yml) mount [`skill-office`](../../../../packages/skill/skill-office/README.md) in Desktop, ordinary `dsh web`, and the SDK. This supersedes the mounting decision of [progressive Office skills](2026-09-27-progressive-office-skills.md); its skill structure and shell execution remain current.
The bundled Python environment ([`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md)) mounts only in Desktop and in the SDK when a primary runtime is available. `skill-office` declares `pythonEnvironment: 'workspace-dependencies' | 'user'` explicitly; each bundle row selects the value for its deployment. The default is `'workspace-dependencies'` because older SDK patches replace the complete `config` of the `skill-office` row; such a patch omits the new key and keeps the bundled-environment instructions it was written for.
[`@deepseek-ai/dsh-office-kit`](../../../../packages/document/office-kit/README.md) owns Kit location and the shared conversion service. Its `/locate` entry accepts only explicit arguments: the consumer's own `import.meta.resolve` lookup configured `node` and `cli` paths, and an optional automatic-discovery `root`. Automatic CLI and native engine paths share ASAR-unpacked mapping. The Desktop shell sets `DSH_BUNDLED_RUNTIME_ROOT` to the dsh runtime directory only for production runtimes, including ASAR and copied release trees; the Web bundle passes it as `kitRoot` and leaves CLI discovery to the Host. With a root, the resolver rejects paths outside the physical runtime after realpath, preventing lookup from selecting the Electron shell's own Kit. Development explicitly clears the inherited variable and, like Web, leaves discovery unrestricted because workspace dependency links resolve outside the disposable runtime. It reads no environment variables; deployment-specific paths reach it through `cordis.yml` expressions in each bundle. The entry imports only Node built-in modules as values, so `skill-office` depends on it without loading Cordis services, Schemastery, or Kit.
An absent Kit degrades instead of failing a load. The conversion service registers and rejects conversions with code `unavailable`, retaining only a successful Kit import and reporting each distinct failure once. Operation skills state that Kit is not available and log the lookup failure once. A configured `node` or `cli` path that is not an absolute existing file still fails the skill load, because that value comes from the composition.
`office-to-pdf` became `office-kit` so that later Kit consumers, such as CLI paths for further deployments and native rendering (PR #5374), take Kit from one package. LibreOffice Kit dependencies declare one exact version in every manifest, so the converter, CLI, and packaged engine come from the same Kit release.
## Alternatives considered
**Keep Office skills out of ordinary Web.** This keeps ordinary Web free of Office guidance it might not execute, but leaves Web users without document authoring that needs only Python libraries. Skills now direct the model to the user's Python when no bundled environment exists.
**Default `pythonEnvironment` to `'user'`.** This matches ordinary Web, but an older SDK patch that replaces the row's `config` would silently switch Desktop-like SDK deployments to the user's Python.
**Read deployment environment variables inside the locator.** This removes the `cordis.yml` expressions, but hides deployment configuration inside a library and makes source, Web, Desktop, and SDK behavior depend on ambient variables. Bundles already own deployment-specific expressions.
**Fail the conversion row when Kit is absent.** This reports the absence at load, which the misconfiguration rule prefers, but the absence belongs to the deployment rather than the configuration, and it disables preview rendering paths and dependent skills that do not need Kit.
**Keep a separate locator package.** A package without Cordis dependencies isolates `skill-office` from the service, but duplicates release, dependency-policy, and documentation configuration for one closely coupled component. The `/locate` subpath provides the same import isolation.
## Consequences
Ordinary Web and SDK sessions without a bundled environment list the seven Office skills, which adds their summaries to the first context. Deployments without Kit see `unavailable` previews and skills that report the missing Kit instead of failing at startup; a later Kit installation takes effect for conversion without reloading the provider. External compositions migrate from the `office-to-pdf` row, `ctx.officeToPdf`, and the `officeToPdf/*` Remote methods through the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/office-kit-package/guide.md).
Processes launched from the Desktop built-in terminal inherit `DSH_BUNDLED_PRIMARY_RUNTIME`, so the SDK uses its bundled environment. Production children also inherit `DSH_BUNDLED_RUNTIME_ROOT`, but only the Desktop profile uses it. The shell does not publish a Kit CLI variable; child applications resolve their own Kit unless explicitly configured by their launcher.
Verification covers the `/locate` import restriction, Kit lookup and engine resolution, conversion retry after a missing or unloadable Kit, deduplicated warnings, operation skills with an absent Kit, recorded Web and SDK sessions that list the Office skills, and Office profile end-to-end tests.
@@ -0,0 +1,41 @@
# Agent Note: Office 技能在所有部署中可用,由单一 Office Kit 包提供 Kit
Status: implemented
[English](2026-10-26-cross-deployment-office-kit.md) | 中文
## 问题
原先只有 Desktop 和 Python SDK 挂载 Office 技能,普通 `dsh web` 用户无法借助同样的指导创建或检查 Office 文档。各部署定位 LibreOffice Kit 的方式也不同:转换提供方、技能和 Desktop 壳各自查找 Kit,Kit 缺失时转换配置行加载失败。没有下载 Office sidecar 的 Python SDK 可执行程序、没有原生引擎的源码检出,都属于部署缺少 Kit,而不是组合配置错误;让配置行失败会隐藏提供方和技能,而它们的大部分行为并不需要 Kit。
## 决策
[Web bundle](../../../../packages/bundle/web-app/cordis.patch.yml) 和 [SDK bundle](../../../../packages/bundle/sdk-app/cordis.patch.yml) 在 Desktop、普通 `dsh web` 和 SDK 中都挂载 [`skill-office`](../../../../packages/skill/skill-office/README.zh.md)。这取代了[渐进式 Office 技能](2026-09-27-progressive-office-skills.zh.md)中的挂载决策;其技能结构和 shell 执行方式仍然有效。
内置 Python 环境([`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md))只在 Desktop 以及有主运行时的 SDK 中挂载。`skill-office` 显式声明 `pythonEnvironment: 'workspace-dependencies' | 'user'`,由各 bundle 的配置行按部署选择取值。默认值为 `'workspace-dependencies'`,因为旧 SDK patch 会整体替换 `skill-office` 行的 `config`;这样的 patch 不含新键,仍保留它所针对的内置环境说明。
[`@deepseek-ai/dsh-office-kit`](../../../../packages/document/office-kit/README.zh.md) 负责 Kit 定位和共享转换服务。其 `/locate` 入口只接收显式参数:消费方自己的 `import.meta.resolve` 查找函数,配置的 `node` 和 `cli` 路径,以及可选的自动查找 `root`。自动 CLI 路径与原生引擎路径共用 ASAR 解包映射。Desktop 壳仅为生产运行时设置 `DSH_BUNDLED_RUNTIME_ROOT`,值为 dsh 运行时目录,包括 ASAR 和真实复制的 release 树;Web bundle 将其作为 `kitRoot` 传入,由 Host 查找 CLI。提供 root 时,解析器拒绝 realpath 后位于实体运行时外的路径,防止查找到 Electron 壳自己的 Kit。开发运行时显式清除继承的变量,与 Web 一样不限制查找范围,因为工作区依赖链接会解析到一次性运行时外。它不读取环境变量;部署专有路径通过各 bundle 中的 `cordis.yml` 表达式传入。该入口只以值的形式导入 Node 内置模块,因此 `skill-office` 依赖它时不会加载 Cordis 服务、Schemastery 或 Kit。
Kit 缺失时降级,而不是加载失败。转换服务照常注册,以代码 `unavailable` 拒绝转换,只保留成功的 Kit 导入,并对每种不同的失败只报告一次。操作技能说明 Kit 不可用,并只记录一次查找失败。配置的 `node` 或 `cli` 不是存在的绝对路径文件时,技能加载仍然失败,因为该值来自组合配置。
`office-to-pdf` 改为 `office-kit`,使后续的 Kit 消费方(例如面向更多部署的 CLI 路径和原生渲染,PR #5374)都从同一个包获取 Kit。LibreOffice Kit 依赖在每个清单中声明同一个精确版本,使转换器、CLI 和打包的引擎来自同一个 Kit 版本。
## 考虑过的替代方案
**普通 Web 继续不挂载 Office 技能。** 这样普通 Web 不会出现它可能无法执行的 Office 指导,但 Web 用户也无法使用只需要 Python 库的文档创作。现在没有内置环境时,技能会引导模型使用用户的 Python。
**`pythonEnvironment` 默认取 `'user'`。** 这与普通 Web 一致,但整体替换该行 `config` 的旧 SDK patch 会让类似 Desktop 的 SDK 部署悄然改用用户的 Python。
**在定位函数中读取部署环境变量。** 这样可以去掉 `cordis.yml` 表达式,但会把部署配置藏进库中,使源码、Web、Desktop 和 SDK 的行为依赖环境变量。bundle 本来就负责部署专有的表达式。
**Kit 缺失时让转换配置行失败。** 这样能在加载时报告缺失,符合配置错误应尽早报告的规则;但缺失属于部署环境而非配置,而且会停用不需要 Kit 的预览渲染路径和依赖技能。
**保留独立的定位包。** 没有 Cordis 依赖的独立包可以让 `skill-office` 与服务隔离,但要为一个紧密耦合的组件重复维护发布、依赖策略和文档配置。`/locate` 子路径提供了同样的导入隔离。
## 影响
没有内置环境的普通 Web 和 SDK 会话会列出七个 Office 技能,首个上下文因此增加它们的摘要。没有 Kit 的部署看到的是 `unavailable` 预览和报告 Kit 缺失的技能,而不是启动失败;之后安装的 Kit 无需重新加载提供方即可用于转换。外部组合按[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/office-kit-package/guide.zh.md)从 `office-to-pdf` 配置行、`ctx.officeToPdf` 和 `officeToPdf/*` Remote 方法迁移。
从 Desktop 内置终端启动的进程会继承 `DSH_BUNDLED_PRIMARY_RUNTIME`,因此 SDK 使用其内置环境。生产版子进程还会继承 `DSH_BUNDLED_RUNTIME_ROOT`,但只有 Desktop profile 使用它。壳不发布 Kit CLI 变量;子应用解析自己的 Kit,除非其启动器显式配置。
验证覆盖 `/locate` 的导入限制、Kit 查找与引擎解析、Kit 缺失或无法加载后的转换重试、去重后的警告、Kit 缺失时的操作技能、列出 Office 技能的 Web 和 SDK 录制会话,以及 Office profile 端到端测试。
@@ -12,8 +12,8 @@
en: f962826fcf5e3949
zh: 8a9932d86332fb47
/agent-note-browser-excel-preview-with-saved-formula-results/alternatives-considered:
en: 0747bca32af26bfc
zh: 1d2739208e7c7d7c
en: 91a98713cd1316be
zh: 2a27af0f5b8a5e46
/agent-note-browser-excel-preview-with-saved-formula-results/consequences:
en: 62595b9a9b948422
zh: 0618904d9ebfa7fc
@@ -36,7 +36,7 @@ The core patch also skips drawing freeze dividers when `allowEdit === false`, re
**Replace ExcelJS or require users to rewrite their files.** A parser replacement puts retained fonts, borders, merges, and frozen panes at risk. Rewriting a source file makes a viewer defect the user's responsibility. A temporary preview copy preserves both the existing adapter and the user's file.
**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 reference](../../../../packages/document/office-to-pdf/README.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 reference](../../../../packages/document/office-kit/README.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets.
## Consequences
@@ -36,7 +36,7 @@ core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保
**替换 ExcelJS 或要求用户重写文件。** 更换解析器会使现有字体、边框、合并及冻结窗格的保留面临风险。重写源文件则把查看器缺陷交给用户处理。临时预览副本同时保留现有适配器和用户文件。
**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎参考](../../../../packages/document/office-to-pdf/README.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。
**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎参考](../../../../packages/document/office-kit/README.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。
## 影响
@@ -6,11 +6,11 @@
en: 9966b1c4fdb2585b
zh: 4eb24c283facf38d
/agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/problem:
en: bb725ab25ff39f86
zh: d198514119b31a61
en: a3e6453fd9d38eb3
zh: accb5062ae04673e
/agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/proposal:
en: e33db390dbbcc86c
zh: 890df8891260103a
en: b9b3e2dd1f8b4ff5
zh: d751ef9cdff21de9
/agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/alternatives-considered:
en: 597346036314c907
zh: 91661d961623c7cd
@@ -6,15 +6,15 @@ English | [中文](2026-09-17-defer-office-background-scheduling.zh.md)
## Problem
The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits.
The [conversion queue](../../../../packages/document/office-kit/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits.
The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed.
The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeKit`, `OfficeKitRequest`, `OfficeKitPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed.
The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions.
## Proposal
Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit.
Remove `OfficeKitPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit.
Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection.
@@ -6,15 +6,15 @@ Status: proposed
## 问题
[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。
[转换队列](../../../../packages/document/office-kit/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。
[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。
[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeKit`、`OfficeKitRequest`、`OfficeKitPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。
[有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。
## 提案
移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。
移除 `OfficeKitPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。
保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。
@@ -572,7 +572,9 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli
if (address === null || typeof address === 'string') throw new Error('model fixture did not bind')
const home = join(root, 'home')
const cliPatch = join(root, 'cli.patch.yml')
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false, pythonEnvironment: 'workspace-dependencies' } }] : []))
// This patch replaces the row's config without pythonEnvironment, as patches written before that option did;
// the skill-office default keeps directing the model to the bundled environment.
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : []))
const officeLaunch = resolveExampleLaunch({
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib',
configArgs: ['--profile', 'sdk', '--patch', cliPatch],
+2 -2
View File
@@ -27,8 +27,8 @@
en: 3724c53da4f7647b
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: 8a1e304605a27e0a
zh: f35e080335d10447
en: d0b1d09858ca4780
zh: beaa57dc224264ce
/deepseek-harness-desktop/bundled-command-runtime:
en: 372297005b57a08b
zh: 074a635ac17e4362
+4 -2
View File
@@ -103,9 +103,11 @@ Signed Windows packaging scans the primary runtime and application production de
Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. The shell resolves the Kit CLI relative to `dsh-skill-office/package.json`; ASAR runtimes use that anchor under the physical unpacked runtime and reject Kit paths outside it. The skill package itself may remain in ASAR. Skills live in `runtime/office-skills` outside ASAR. A missing or invalid skill file removes only that skill from the catalog, and Office profile rows that cannot load leave Office skills or `load_workspace_dependencies` unavailable; neither failure stops Host startup. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. In the Host, `skill-office` resolves its Kit CLI through `office-kit/locate`, which maps ASAR paths to physical unpacked files. Production runtimes supply `kitRoot` and reject automatic discoveries outside that root after realpath; development leaves discovery unrestricted so workspace dependency links remain usable. The skill package itself may remain in ASAR. Skills live in `runtime/office-skills` outside ASAR. A missing or invalid skill file removes only that skill from the catalog, and Office profile rows that cannot load leave Office skills or `load_workspace_dependencies` unavailable; neither failure stops Host startup. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
If the shell cannot locate the Kit CLI, it clears `DSH_BUNDLED_OFFICE_CLI` from the Host environment and the Office profile row uses `cli: false`. Readable Office skills remain registered; operation instructions report that Kit is disabled instead of publishing executable paths. The independent dependency-query row is unaffected. The shell writes the latest failure, timestamp, and reinstall guidance to `office-kit-unavailable.log` in Electron's application logs directory; a write failure goes to stderr without stopping the Host. There is no GUI notification. Required Host components must still start successfully, and packaging smoke checks still reject an unavailable Kit.
If Kit cannot be located, or a production runtime rejects its location, readable Office skills remain registered; operation instructions report “LibreOffice Kit is not available in this deployment.” instead of publishing executable paths. The skill provider logs each distinct failure once through `ctx.logger.warn`. The independent dependency-query row is unaffected. There is no separate diagnostic file or GUI notification. Required Host components must still start successfully, and packaging smoke checks reject an unavailable Kit.
Processes started from the Desktop built-in terminal inherit `DSH_BUNDLED_PRIMARY_RUNTIME`, so `dsh --profile sdk` started there uses Desktop's bundled Python environment. Unset that variable to use the user's Python. Production Desktop also passes `DSH_BUNDLED_RUNTIME_ROOT`, set to its dsh runtime directory, to the Host and inherited child processes. Only the Desktop profile reads it as `kitRoot`; other profiles ignore it. Development explicitly clears any inherited value. Desktop does not publish a Kit CLI environment variable; child applications resolve their own Kit unless their launcher explicitly configures a CLI.
The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
+4 -2
View File
@@ -103,9 +103,11 @@ Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产
Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。壳以 `dsh-skill-office/package.json` 为锚点解析 Kit CLI;ASAR 运行时在实体解包运行时下使用该锚点,并拒绝指向其外部的 Kit 路径。技能包本身可以保留在 ASAR 内。技能位于 ASAR 外的 `runtime/office-skills`。缺失或无效的技能文件只会把该技能移出目录;Office 配置行无法加载时,Office 技能或 `load_workspace_dependencies` 不可用。这两种失败都不会阻止 Host 启动。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。Host 中的 `skill-office` 通过 `office-kit/locate` 解析自己的 Kit CLI;解析器把 ASAR 路径映射到实体解包文件。生产运行时提供 `kitRoot`,拒绝 realpath 后位于该根目录外的自动查找结果;开发运行时不限制查找范围,使工作区依赖链接仍可用。技能包本身可以保留在 ASAR 内。技能位于 ASAR 外的 `runtime/office-skills`。缺失或无效的技能文件只会把该技能移出目录;Office 配置行无法加载时,Office 技能或 `load_workspace_dependencies` 不可用。这两种失败都不会阻止 Host 启动。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
壳无法定位 Kit CLI 时,会从 Host 环境中清除 `DSH_BUNDLED_OFFICE_CLI`,Office 配置行使用 `cli: false`。可读的 Office 技能仍会注册;操作说明报告 Kit 已禁用,不再提供可执行文件路径。独立的依赖查询行不受影响。壳把最近一次失败、时间戳和重装指引写入 Electron 应用日志目录中的 `office-kit-unavailable.log`;写入失败时输出到 stderr,不阻止 Host 启动。不提供 GUI 通知。Host 必需组件仍须成功启动,打包冒烟检查仍会拒绝不可用的 Kit。
无法定位 Kit,或生产运行时拒绝其位置时,可读的 Office 技能仍会注册;操作说明报告 “LibreOffice Kit is not available in this deployment.”,不提供可执行文件路径。技能提供方通过 `ctx.logger.warn` 对每种不同的失败只记录一次。独立的依赖查询行不受影响。不提供独立诊断文件或 GUI 通知。Host 必需组件仍须成功启动,打包冒烟检查会拒绝不可用的 Kit。
从 Desktop 内置终端启动的进程会继承 `DSH_BUNDLED_PRIMARY_RUNTIME`,因此在其中启动的 `dsh --profile sdk` 使用 Desktop 的内置 Python 环境。要改用用户的 Python,请取消设置该变量。生产版 Desktop 还向 Host 及继承其环境的子进程传入 `DSH_BUNDLED_RUNTIME_ROOT`,值为其 dsh 运行时目录。只有 Desktop profile 将它作为 `kitRoot` 读取,其他 profile 忽略它。开发版显式清除任何继承值。Desktop 不发布 Kit CLI 环境变量;子应用解析自己的 Kit,除非其启动器显式配置 CLI。
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
+1
View File
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*",
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/dsh-office-kit": "workspace:*",
"@deepseek-ai/dsh-official-bundles": "workspace:*",
"@deepseek-ai/node-addon-system": "workspace:~",
"@electron/get": "^5.1.0",
+2 -2
View File
@@ -252,9 +252,9 @@ Desktop packs workspace packages locally and installs external dependencies thro
The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions.
The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) is a Host service mounted by web-app, so every application that loads web-app, including `dsh web` and Desktop, provides it. The provider and the [engine-package resolver](../../../packages/document/office-engine-resolver/README.md) resolve the Kit's engine, including the mapping of native engine manifests to unpacked physical directories. Desktop packaging only carries and unpacks the engine: it carries the target’s declared native engine, or WASM when the kit declares no matching native target, and preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop shell resolves the primary runtime and unpacked CLI paths in [office-cli.ts](../src/office-cli.ts) and [main.ts](../src/main.ts), then passes them into the Host environment through `DSH_BUNDLED_PRIMARY_RUNTIME` and `DSH_BUNDLED_OFFICE_CLI`. The `skill-office` row in web-app's [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) uses these entries to configure the skills' absolute standalone Node and CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. Office skills are available in every application that loads web-app; only Desktop mounts the bundled Python environment (`workspace-dependencies`). On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy.
The [Office conversion provider](../../../packages/document/office-kit/README.md) is a Host service mounted by web-app, so every application that loads web-app, including `dsh web` and Desktop, provides it. The provider and its [`/locate` entry](../../../packages/document/office-kit/README.md#locate-kit) resolve the Kit's engine, including the mapping of native engine manifests to unpacked physical directories. Desktop packaging only carries and unpacks the engine: it carries the target’s declared native engine, or WASM when the kit declares no matching native target, and preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop shell passes `DSH_BUNDLED_PRIMARY_RUNTIME` in [main.ts](../src/main.ts) and sets `DSH_BUNDLED_RUNTIME_ROOT` to the dsh runtime directory for production runtimes, including ASAR and copied release trees. Development explicitly clears the latter variable so workspace links can resolve outside the runtime. The `skill-office` row in web-app's [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) derives standalone Node from the primary payload and reads `kitRoot` from `DSH_BUNDLED_RUNTIME_ROOT`; an unset variable leaves discovery unrestricted. CLI discovery and containment belong to `office-kit/locate` in the Host, not the shell. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. Office skills are available in every application that loads web-app; only Desktop mounts the bundled Python environment (`workspace-dependencies`). On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) first calls `resolveKitCli` with the runtime root and a lookup anchored at its `dsh-skill-office` package in the physical unpacked tree; any result other than `available` rejects packaging. It then creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
Windows release qualification also runs [directory and replacement checks](../scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
+2 -2
View File
@@ -252,9 +252,9 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。
[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)是 web-app 挂载的 Host 服务,因此所有加载 web-app 的应用(包括 `dsh web` 和 Desktop)都提供它。该提供方与[引擎包解析器](../../../packages/document/office-engine-resolver/README.zh.md)负责解析 Kit 的引擎,包括把原生引擎清单映射到解包后的物理目录。Desktop 打包只负责携带和解包引擎:携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎;准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop 壳在 [office-cli.ts](../src/office-cli.ts) 和 [main.ts](../src/main.ts) 中解析主运行时及解包后 CLI 的路径,再通过 `DSH_BUNDLED_PRIMARY_RUNTIME` 和 `DSH_BUNDLED_OFFICE_CLI` 传入 Host 环境。web-app 的 [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) 中的 `skill-office` 配置行使用这些环境变量,为技能配置独立 Node 和 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。Office 技能在所有加载 web-app 的应用中可用;只有 Desktop 挂载内置 Python 环境(`workspace-dependencies`)。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 `@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。
[Office 转换提供方](../../../packages/document/office-kit/README.zh.md)是 web-app 挂载的 Host 服务,因此所有加载 web-app 的应用(包括 `dsh web` 和 Desktop)都提供它。该提供方及其 [`/locate` 入口](../../../packages/document/office-kit/README.zh.md#locate-kit)负责解析 Kit 的引擎,包括把原生引擎清单映射到解包后的物理目录。Desktop 打包只负责携带和解包引擎:携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎;准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop 壳在 [main.ts](../src/main.ts) 中传入 `DSH_BUNDLED_PRIMARY_RUNTIME`,并为生产运行时将 `DSH_BUNDLED_RUNTIME_ROOT` 设为 dsh 运行时目录,包括 ASAR 和真实复制的 release 树。开发运行时显式清除后一个变量,允许工作区链接解析到运行时外。web-app 的 [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) 中的 `skill-office` 行从主载荷确定独立 Node,并从 `DSH_BUNDLED_RUNTIME_ROOT` 读取 `kitRoot`;变量未设置时不限制查找范围。CLI 查找与越界检查由 Host 中的 `office-kit/locate` 负责,不由壳负责。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。Office 技能在所有加载 web-app 的应用中可用;只有 Desktop 挂载内置 Python 环境(`workspace-dependencies`)。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 `@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 首先调用 `resolveKitCli`,传入运行时根目录以及锚定到实体解包树中 `dsh-skill-office` 包的查找函数;任何非 `available` 结果都会拒绝打包。随后使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。Host smoke 还从加载的技能中取得 CLI 路径,在空 PATH 下运行 capabilities 和 DOCX 转换。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](../scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
+16 -7
View File
@@ -9,7 +9,9 @@ import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { readPrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts'
import { DesktopHostProcess } from '../src/host-process.ts'
import { desktopOfficeEnvironment } from '../src/office-cli.ts'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import { resolveKitCli, unpackedPath } from '@deepseek-ai/dsh-office-kit/locate'
import { createPluginProfile } from '../src/project-manager.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
@@ -25,9 +27,16 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
export async function smokeDesktopRuntime(
root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string,
): Promise<void> {
const office = desktopOfficeEnvironment(root, join(resourcesRuntime, 'primary-runtime'))
// Desktop starts without a Kit CLI; a release that cannot locate one is still broken.
if (office.kind !== 'ready') throw new Error('desktop runtime: LibreOffice Kit CLI is missing from the runtime', { cause: office.error })
// Plain Node cannot read ASAR; the skill package anchor need not exist in the unpacked tree.
const anchor = unpackedPath(join(root, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json'))
const office = resolveKitCli({
root,
lookupKitEntry: () => pathToFileURL(createRequire(anchor).resolve('@deepseek-ai/libreoffice-kit')),
})
if (office.kind !== 'available') {
throw new Error('desktop runtime: LibreOffice Kit CLI is missing from the runtime',
{ cause: office.kind === 'missing' ? office.error : undefined })
}
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
const profile = join(home, 'profiles', 'desktop')
const hostEnvironment = {
@@ -39,7 +48,7 @@ export async function smokeDesktopRuntime(
npm_config_store_dir: join(home, 'store'), npm_config_enable_global_virtual_store: 'false',
}
const host = new DesktopHostProcess(node, root, profile, undefined,
{ ...hostEnvironment, ...office.environment },
{ ...hostEnvironment, DSH_BUNDLED_PRIMARY_RUNTIME: join(resourcesRuntime, 'primary-runtime'), DSH_BUNDLED_RUNTIME_ROOT: root },
undefined,
{ pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') })
let timer: ReturnType<typeof setTimeout> | undefined
@@ -128,7 +137,7 @@ export function apply(ctx) {
async handler(_request, response) {
try {
const bytes = Buffer.from(input.bytes, 'base64')
const result = await ctx.officeToPdf.convert({ extension: input.extension, priority: 'foreground',
const result = await ctx.officeKit.convert({ extension: input.extension, priority: 'foreground',
source: { key: 'desktop-smoke-' + input.extension, version: 'fixture', bytes: bytes.length,
async read() { return { bytes, version: 'fixture' } } } })
response.end(Buffer.from(result.pdf))
@@ -140,7 +149,7 @@ export function apply(ctx) {
}
}
`)
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n')
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeKit, skills, pluginManager]\n')
cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true })
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>
+5 -10
View File
@@ -28,8 +28,7 @@ import {
} from 'electron'
import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError, type DesktopHostReady } from './host-process.ts'
import { desktopOfficeEnvironment, writeOfficeKitDiagnostic } from './office-cli.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopFileDrag } from './file-drag.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
@@ -525,11 +524,12 @@ async function main(): Promise<void> {
owner => isMandatory() || updateOverlays.input(owner).blocked)
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const office = desktopOfficeEnvironment(resources.dsh, primaryRuntime)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, {
...hostEnvironment,
...office.environment,
DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime,
// Development dependencies resolve through links outside the disposable runtime.
DSH_BUNDLED_RUNTIME_ROOT: development ? undefined : resources.dsh,
DSH_CLIENT_VERSION: desktopClientVersion(),
}, onFailure,
resources, (next) => { platformView.setSession(next) }, (record) => {
@@ -540,12 +540,7 @@ async function main(): Promise<void> {
})
return {
start: async () => {
// Spawn in the same turn: awaiting the record first would let stop() finish before the child exists.
const diagnostic = office.kind === 'kit-unavailable'
? writeOfficeKitDiagnostic(app.getPath('logs'), resources.dsh, office.error, new Date()) : undefined
const started = host.start()
let ready: DesktopHostReady
try { ready = await started } finally { await diagnostic }
const ready = await host.start()
const cookie = await authenticateWebHost(ready.url)
hostCookie = cookie
hostUrl = ready.url
-107
View File
@@ -1,107 +0,0 @@
/** Bundled Office locations that the Desktop profile's Office rows read from the Host environment. */
import { realpathSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
import { inspect } from 'node:util'
/**
* Locate the LibreOffice Kit CLI entry that a standalone Node child process can execute.
* @param runtimeDir - Prepared or ASAR-contained dsh runtime directory that carries the Office skill package.
* @returns Absolute CLI entry; ASAR runtimes resolve only from their physical unpacked runtime.
* @throws When the unpacked runtime or Kit is missing; callers must not supply any Kit CLI path.
* Missing Kit errors use `MODULE_NOT_FOUND` with reinstall guidance; failed resolution retains its original error as `cause`.
* Other errors propagate unchanged.
*/
export function officeCliPath(runtimeDir: string): string {
const packed = basename(dirname(runtimeDir)) === 'app.asar'
// Plain Node cannot traverse ASAR entries; canonicalize only the archive and physical unpacked directory.
const directory = packed
? realpathSync(join(`${realpathSync(dirname(runtimeDir))}.unpacked`, basename(runtimeDir)))
: runtimeDir
// createRequire needs an anchor path, not an existing file: skill-office itself can remain in the archive.
const anchor = join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json')
let manifest: string
try { manifest = createRequire(anchor).resolve('@deepseek-ai/libreoffice-kit/package.json') } catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND')) throw error
throw Object.assign(new Error(`LibreOffice Kit is missing from the Desktop runtime: ${directory}. Reinstall Desktop.`,
{ cause: error }), { code: 'MODULE_NOT_FOUND' })
}
if (packed) {
const path = relative(directory, realpathSync(manifest))
if (isAbsolute(path) || path === '..' || path.startsWith(`..${sep}`)) {
throw Object.assign(new Error(`LibreOffice Kit is missing from the unpacked runtime: ${directory}. Reinstall Desktop.`),
{ code: 'MODULE_NOT_FOUND' })
}
}
return join(dirname(manifest), 'lib', 'cli.js')
}
/**
* Office carrier entries for the Host environment. The explicit `undefined` CLI entry replaces any inherited value,
* so the Desktop profile's `skill-office` row receives `cli: false` instead of a stale or externally resolved Kit.
*/
export type DesktopOfficeEnvironment =
| {
readonly kind: 'ready'
readonly environment: { readonly DSH_BUNDLED_PRIMARY_RUNTIME: string; readonly DSH_BUNDLED_OFFICE_CLI: string }
}
| {
readonly kind: 'kit-unavailable'
readonly environment: { readonly DSH_BUNDLED_PRIMARY_RUNTIME: string; readonly DSH_BUNDLED_OFFICE_CLI: undefined }
/** The {@link officeCliPath} failure, unchanged. */
readonly error: unknown
}
/**
* Environment entries that point the Desktop profile's Office rows at bundled resources.
* Locating the Kit CLI only reads the filesystem; its failure leaves the primary runtime entry and the Office skills
* usable, so it is returned instead of thrown. Release verification must reject `kit-unavailable`.
* @param runtimeDir - dsh runtime directory passed to the Desktop Host.
* @param primaryRuntime - Bundled Python, Node.js, and pnpm payload whose sibling `office-skills` holds the skills.
* @returns `ready` with both entries, or `kit-unavailable` with the {@link officeCliPath} error.
*/
export function desktopOfficeEnvironment(runtimeDir: string, primaryRuntime: string): DesktopOfficeEnvironment {
let cli: string
try { cli = officeCliPath(runtimeDir) } catch (error) {
return { kind: 'kit-unavailable', environment: { DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: undefined }, error }
}
return { kind: 'ready', environment: { DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: cli } }
}
/** Diagnostic file that {@link writeOfficeKitDiagnostic} replaces in the application logs directory. */
export const OFFICE_KIT_DIAGNOSTIC = 'office-kit-unavailable.log'
/**
* Record a Kit CLI location failure in the application logs directory, replacing the previous record.
* A write failure is reported to `console.error` with the original failure; the returned promise never rejects.
* @param directory - Application logs directory.
* @param runtimeDir - dsh runtime directory whose Kit could not be located.
* @param error - The `kit-unavailable` error.
* @param time - Time of the Host start that omitted the CLI.
* @returns The written path, or `undefined` when writing failed.
*/
export async function writeOfficeKitDiagnostic(
directory: string, runtimeDir: string, error: unknown, time: Date,
): Promise<string | undefined> {
const path = join(directory, OFFICE_KIT_DIAGNOSTIC)
try {
const report = [
`time: ${time.toISOString()}`,
`runtime: ${runtimeDir}`,
'impact: the LibreOffice Kit CLI is unavailable to Office skills; Office skills and the Desktop Host continue without CLI paths.',
'action: reinstall Desktop to restore the bundled LibreOffice Kit.',
'',
'--- error ---',
inspect(error, { depth: 6, breakLength: 120 }),
'',
].join('\n')
await mkdir(directory, { recursive: true, mode: 0o700 })
await writeFile(path, report, { mode: 0o600 })
return path
} catch (failure) {
console.error('dsh desktop: LibreOffice Kit diagnostic could not be written; Office skills continue without CLI paths', path, failure, error)
return undefined
}
}
+15 -96
View File
@@ -15,7 +15,6 @@ import { en, zh } from '../src/locale.ts'
import { DesktopUpdatePreparationError } from '../src/update-error.ts'
import { writeCrashReport } from '../src/crash-report.ts'
import { runOperationReportJob } from '../src/operation-report-job.ts'
import { OFFICE_KIT_DIAGNOSTIC, desktopOfficeEnvironment, writeOfficeKitDiagnostic, type DesktopOfficeEnvironment } from '../src/office-cli.ts'
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
@@ -348,17 +347,6 @@ vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
readDesktopLoginShellEnvironment: harness.loginShell,
}))
vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) }))
// Most cases need a located Kit without a runtime tree; Office cases restore the real locator over fixtures.
vi.mock('../src/office-cli.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/office-cli.ts')>()
return {
...actual,
desktopOfficeEnvironment: vi.fn((runtime: string, primaryRuntime: string): DesktopOfficeEnvironment => ({ kind: 'ready', environment: {
DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: join(runtime, 'office-cli.js'),
} })),
writeOfficeKitDiagnostic: vi.fn(actual.writeOfficeKitDiagnostic),
}
})
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
vi.mock('../src/project-manager.ts', () => ({
DesktopProjectManager: class {
@@ -484,6 +472,8 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', undefined)
vi.stubEnv('DSH_BUNDLED_RUNTIME_ROOT', undefined)
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
@@ -515,8 +505,9 @@ describe('desktop main startup', () => {
expect(host.node).toBe(process.execPath)
expect(host.environment).toMatchObject({
DSH_BUNDLED_PRIMARY_RUNTIME: packaged ? join('desktop-test-resources', 'runtime', 'primary-runtime') : 'test-primary-runtime',
DSH_BUNDLED_OFFICE_CLI: join(host.runtime, 'office-cli.js'),
DSH_BUNDLED_RUNTIME_ROOT: packaged ? join('desktop-test-app', 'dsh') : undefined,
})
expect(host.environment).not.toHaveProperty('DSH_BUNDLED_OFFICE_CLI')
expect(host.packageManager).toMatchObject({
pnpm: packaged
? join('desktop-test-resources', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')
@@ -738,100 +729,28 @@ describe('desktop main startup', () => {
expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' })
})
/** Restore the real Kit locator over a development runtime path whose ASAR sibling has no unpacked runtime. */
async function missingKitRuntime(): Promise<string> {
it.each([true, false])('overrides an inherited Kit root before Host startup (packaged=%s)', async (packaged) => {
harness.app.isPackaged = packaged
vi.stubEnv('DSH_BUNDLED_RUNTIME_ROOT', 'stale-runtime')
const host = await readyForUpdate()
expect(host.environment).toHaveProperty('DSH_BUNDLED_RUNTIME_ROOT', packaged ? host.runtime : undefined)
})
it('starts the Host without resolving Kit or writing a diagnostic file', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-main-office-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
writeFileSync(join(root, 'app.asar'), 'archive fixture')
const actual = await vi.importActual<typeof import('../src/office-cli.ts')>('../src/office-cli.ts')
vi.mocked(desktopOfficeEnvironment).mockImplementationOnce(actual.desktopOfficeEnvironment)
harness.app.isPackaged = false
vi.stubEnv('DSH_DESKTOP_DSH_DIR', join(root, 'app.asar', 'dsh'))
// The login shell environment carries an earlier CLI value that the Office entries must replace.
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', join(root, 'stale-cli.js'))
return join(root, 'app.asar', 'dsh')
}
const launched = () => harness.analytics.mock.calls.some(([event]) => (event as { eventName: string }).eventName === 'desktop_app_launch')
it('starts the Host without a Kit CLI and records why when the bundled Kit cannot be located', async () => {
const runtime = await missingKitRuntime()
const host = await readyForUpdate()
await vi.waitFor(() => { expect(launched()).toBe(true) })
await vi.waitFor(() => { expect(harness.analytics.mock.calls.some(([event]) => (event as { eventName: string }).eventName === 'desktop_app_launch')).toBe(true) })
expect(host.environment).toHaveProperty('DSH_BUNDLED_PRIMARY_RUNTIME', 'test-primary-runtime')
expect(Object.hasOwn(host.environment!, 'DSH_BUNDLED_OFFICE_CLI')).toBe(true)
expect(host.environment!.DSH_BUNDLED_OFFICE_CLI).toBeUndefined()
const report = readFileSync(join(harness.app.getPath('logs'), OFFICE_KIT_DIAGNOSTIC), 'utf8')
expect(report).toContain(`runtime: ${runtime}`)
expect(report).toContain('Office skills and the Desktop Host continue without CLI paths')
expect(report).toContain('reinstall Desktop')
expect(report).toContain('ENOENT')
expect(host.environment).not.toHaveProperty('DSH_BUNDLED_OFFICE_CLI')
expect(existsSync(harness.app.getPath('logs'))).toBe(false)
expect(writeCrashReport).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
})
it('starts the Host when the Kit diagnostic cannot be written', async () => {
await missingKitRuntime()
writeFileSync(harness.app.getPath('logs'), 'not a directory')
await readyForUpdate()
await vi.waitFor(() => { expect(launched()).toBe(true) })
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('LibreOffice Kit diagnostic could not be written'),
join(harness.app.getPath('logs'), OFFICE_KIT_DIAGNOSTIC), expect.any(Error), expect.objectContaining({ code: 'ENOENT' }))
expect(writeCrashReport).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
})
it('settles Host readiness only after the pending Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
const host = await readyForUpdate()
await vi.advanceTimersByTimeAsync(0)
expect(launched()).toBe(false)
diagnostic.resolve(undefined)
await vi.waitFor(() => { expect(launched()).toBe(true) })
expect(host.stop).not.toHaveBeenCalled()
})
it('reports a Host startup failure as fatal only after the pending Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
const host = harness.hosts[0]!
host.ready.reject(new Error('core services failed'))
await vi.advanceTimersByTimeAsync(0)
expect(host.stop).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
diagnostic.resolve(undefined)
await host.stopping.promise
host.exited.resolve()
await harness.dialogShown.promise
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('core services failed')
})
it('stops the spawned Host when quit arrives during the Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
const host = harness.hosts[0]!
harness.app.quit()
await host.stopping.promise
host.exited.resolve()
diagnostic.resolve(undefined)
await harness.quitCompleted.promise
expect(host.stop).toHaveBeenCalledOnce()
expect(harness.hosts).toHaveLength(1)
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
})
it('starts the Host with authentication popups disabled when local settings are invalid', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
-275
View File
@@ -1,275 +0,0 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts'
import { desktopNodeEnvironment } from '../src/node-environment.ts'
import { OFFICE_KIT_DIAGNOSTIC, desktopOfficeEnvironment, officeCliPath, writeOfficeKitDiagnostic } from '../src/office-cli.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
const roots: string[] = []
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function temporaryRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-cli-')))
roots.push(root)
return root
}
function installPackage(directory: string, name: string): string {
const pkg = join(directory, 'node_modules', '@deepseek-ai', name)
mkdirSync(join(pkg, 'lib'), { recursive: true })
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: `@deepseek-ai/${name}` }))
writeFileSync(join(pkg, 'lib', 'cli.js'), 'export {}\n')
return pkg
}
function runtime(directory: string): string {
installPackage(directory, 'dsh-skill-office')
return join(installPackage(directory, 'libreoffice-kit'), 'lib', 'cli.js')
}
// pnpm's NODE_PATH exposes workspace packages; missing-package fixtures need plain Node's local search only.
function resolveWithoutGlobalPackages(directory: string): unknown {
return JSON.parse(execFileSync(process.execPath, ['--no-global-search-paths', '--input-type=module', '-e', `
import { officeCliPath } from ${JSON.stringify(new URL('../src/office-cli.ts', import.meta.url).href)}
try {
console.log(JSON.stringify(officeCliPath(process.argv[1])))
} catch (error) {
console.log(JSON.stringify(error, ['message', 'code', 'cause', 'requireStack']))
}
`, directory], { encoding: 'utf8' }))
}
function archiveRuntime(runtimeName = 'dsh') {
const root = temporaryRoot()
const resources = join(root, '应用 Resources')
mkdirSync(resources)
const archive = join(resources, 'app.asar')
// Plain Node cannot traverse an archive file; this helper must only read its unpacked sibling.
writeFileSync(archive, 'archive fixture')
return { root, resources, archive, directory: join(archive, runtimeName), unpacked: join(`${archive}.unpacked`, runtimeName) }
}
it('returns the CLI entry inside a prepared runtime', () => {
const directory = join(temporaryRoot(), '准备 runtime')
const cli = runtime(directory)
expect(officeCliPath(directory)).toBe(cli)
})
it('preserves default skill-office dependency resolution outside ASAR', () => {
const directory = join(temporaryRoot(), 'dsh')
runtime(directory)
const skill = join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office')
const kit = installPackage(skill, 'libreoffice-kit')
expect(officeCliPath(directory)).toBe(join(kit, 'lib', 'cli.js'))
})
it('rejects kit available only through the Desktop Host package in a prepared runtime', () => {
const directory = join(temporaryRoot(), 'dsh')
const host = installPackage(directory, 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
expect(createRequire(join(host, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(kit, 'package.json'))
expect(resolveWithoutGlobalPackages(directory)).toEqual(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('reports a missing kit with reinstall guidance and the original resolution error', () => {
const directory = join(temporaryRoot(), 'dsh')
mkdirSync(directory)
const error = resolveWithoutGlobalPackages(directory)
expect(error).toMatchObject({
message: `LibreOffice Kit is missing from the Desktop runtime: ${directory}. Reinstall Desktop.`,
code: 'MODULE_NOT_FOUND',
cause: {
code: 'MODULE_NOT_FOUND',
requireStack: [join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json')],
},
})
expect(error).toHaveProperty('cause.message', expect.stringContaining("Cannot find module '@deepseek-ai/libreoffice-kit/package.json'"))
})
it('preserves resolution errors other than a missing module', () => {
const directory = join(temporaryRoot(), 'dsh')
const kit = installPackage(directory, 'libreoffice-kit')
writeFileSync(join(kit, 'package.json'), JSON.stringify({
name: '@deepseek-ai/libreoffice-kit', exports: { '.': './lib/cli.js' },
}))
const error = resolveWithoutGlobalPackages(directory)
expect(error).toEqual(expect.objectContaining({ code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }))
expect(error).not.toHaveProperty('cause')
})
it('rejects kit available only through the Desktop Host package', () => {
const f = archiveRuntime()
const host = installPackage(f.unpacked, 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
expect(createRequire(join(host, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(kit, 'package.json'))
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('accepts a prepared runtime directory alias', () => {
const root = temporaryRoot()
const directory = join(root, '准备 runtime')
const cli = runtime(directory)
const alias = join(root, 'runtime alias')
symlinkSync(directory, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(officeCliPath(alias)).toBe(cli)
})
it('resolves an unpacked CLI beside an ASAR file under plain Node', () => {
const f = archiveRuntime()
const cli = runtime(f.unpacked)
// An ancestor package must not substitute for the shipped unpacked copy.
installPackage(f.root, 'libreoffice-kit')
expect(statSync(f.archive).isFile()).toBe(true)
expect(desktopOfficeEnvironment(f.directory, '/resources/runtime/primary-runtime')).toEqual({ kind: 'ready', environment: {
DSH_BUNDLED_PRIMARY_RUNTIME: '/resources/runtime/primary-runtime',
DSH_BUNDLED_OFFICE_CLI: cli,
} })
expect(statSync(cli).isFile()).toBe(true)
})
it('preserves the ASAR runtime basename with spaces and non-ASCII characters', () => {
const f = archiveRuntime('运行 runtime')
const cli = runtime(f.unpacked)
expect(officeCliPath(f.directory)).toBe(cli)
})
it('accepts an ASAR resource directory alias', () => {
const f = archiveRuntime()
const cli = runtime(f.unpacked)
const alias = join(f.root, '资源 alias')
symlinkSync(f.resources, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(officeCliPath(join(alias, 'app.asar', 'dsh'))).toBe(cli)
})
it('accepts an unpacked runtime directory alias', () => {
const f = archiveRuntime()
const physical = join(f.root, '实体 runtime')
const cli = runtime(physical)
mkdirSync(dirname(f.unpacked))
symlinkSync(physical, f.unpacked, process.platform === 'win32' ? 'junction' : 'dir')
expect(officeCliPath(f.directory)).toBe(cli)
})
it('resolves kit from the physical skill-office package through a directory alias', () => {
const f = archiveRuntime()
const skill = installPackage(join(f.unpacked, 'packages'), 'dsh-skill-office')
const kit = installPackage(skill, 'libreoffice-kit')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'dsh-skill-office')
mkdirSync(dirname(alias), { recursive: true })
symlinkSync(skill, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(officeCliPath(f.directory)).toBe(join(kit, 'lib', 'cli.js'))
})
it('fails when the unpacked runtime is missing', () => {
const f = archiveRuntime()
installPackage(f.root, 'libreoffice-kit')
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'ENOENT' }))
})
it('resolves the unpacked kit when skill-office itself remains in the archive', () => {
const f = archiveRuntime()
const kit = installPackage(f.unpacked, 'libreoffice-kit')
expect(officeCliPath(f.directory)).toBe(join(kit, 'lib', 'cli.js'))
})
it('fails when the unpacked kit package is missing', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('rejects an ancestor kit package when the unpacked kit is missing', () => {
const f = archiveRuntime()
const skill = installPackage(f.unpacked, 'dsh-skill-office')
const external = installPackage(f.root, 'libreoffice-kit')
expect(createRequire(join(skill, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(external, 'package.json'))
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('rejects a kit directory alias outside the unpacked runtime', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
const external = installPackage(`${f.unpacked}-external`, 'libreoffice-kit')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
symlinkSync(external, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it.each([
['the unpacked runtime is missing', 'ENOENT', () => archiveRuntime()],
['the unpacked Kit is missing', 'MODULE_NOT_FOUND', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
return f
}],
['the Kit resolves outside the unpacked runtime', 'MODULE_NOT_FOUND', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
symlinkSync(installPackage(`${f.unpacked}-external`, 'libreoffice-kit'), alias, process.platform === 'win32' ? 'junction' : 'dir')
return f
}],
])('keeps the primary runtime and replaces an inherited CLI when %s', (_label, code, fixture) => {
const f = fixture()
const office = desktopOfficeEnvironment(f.directory, '/resources/runtime/primary-runtime')
expect(office).toMatchObject({ kind: 'kit-unavailable', error: { code } })
expect(office.environment).toStrictEqual({ DSH_BUNDLED_PRIMARY_RUNTIME: '/resources/runtime/primary-runtime', DSH_BUNDLED_OFFICE_CLI: undefined })
// The Host child receives its environment through the same Node-mode projection as Desktop.
const inherited: NodeJS.ProcessEnv = { ...process.env, DSH_BUNDLED_OFFICE_CLI: join(f.root, 'stale-cli.js') }
const child = execFileSync(process.execPath, ['-e', 'console.log(JSON.stringify([process.env.DSH_BUNDLED_PRIMARY_RUNTIME, "DSH_BUNDLED_OFFICE_CLI" in process.env]))'], {
encoding: 'utf8',
env: desktopNodeEnvironment(process.execPath, undefined, { ...inherited, ...office.environment }),
})
expect(JSON.parse(child)).toEqual(['/resources/runtime/primary-runtime', false])
})
it('records the Kit failure, its impact, and the reinstall action', async () => {
const f = archiveRuntime()
const office = desktopOfficeEnvironment(f.directory, '/primary-runtime')
if (office.kind !== 'kit-unavailable') throw new Error('fixture located a Kit')
const logs = join(f.root, 'logs')
const path = await writeOfficeKitDiagnostic(logs, f.directory, office.error, new Date('2026-01-02T03:04:05.006Z'))
expect(path).toBe(join(logs, OFFICE_KIT_DIAGNOSTIC))
const report = readFileSync(path!, 'utf8')
expect(report).toContain('time: 2026-01-02T03:04:05.006Z')
expect(report).toContain(`runtime: ${f.directory}`)
expect(report).toContain('impact: the LibreOffice Kit CLI is unavailable to Office skills; Office skills and the Desktop Host continue without CLI paths.')
expect(report).toContain('action: reinstall Desktop to restore the bundled LibreOffice Kit.')
expect(report).toContain('ENOENT')
})
it('reports a failed Kit diagnostic write to stderr without rejecting', async () => {
const root = temporaryRoot()
const logs = join(root, 'logs')
writeFileSync(logs, 'not a directory')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const original = new Error('kit missing')
await expect(writeOfficeKitDiagnostic(logs, join(root, 'dsh'), original, new Date())).resolves.toBeUndefined()
expect(error).toHaveBeenCalledOnce()
const call: readonly unknown[] = error.mock.calls[0]!
const [summary, path, failure, reported] = call
expect(summary).toContain('LibreOffice Kit diagnostic could not be written')
expect(path).toBe(join(logs, OFFICE_KIT_DIAGNOSTIC))
expect(failure).toBeInstanceOf(Error)
expect(typeof (failure as NodeJS.ErrnoException).code).toBe('string')
expect(reported).toBe(original)
})
it('rejects a runtime smoke without a Kit', async () => {
const f = archiveRuntime()
const smoke = smokeDesktopRuntime(f.directory, process.execPath, {} as DesktopRuntimeDescriptor, {}, join(f.root, 'resources'))
const error = await smoke.then(() => undefined, (reason: unknown) => reason)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('desktop runtime: LibreOffice Kit CLI is missing from the runtime')
expect(((error as Error).cause as NodeJS.ErrnoException).code).toBe('ENOENT')
})
+8 -7
View File
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DesktopHostProcess } from '../src/host-process.ts'
import { desktopOfficeEnvironment } from '../src/office-cli.ts'
import { builtHost, prepareDesktopHome, repository } from './harness.ts'
afterEach(() => { vi.unstubAllEnvs() })
@@ -66,13 +65,15 @@ describe.skipIf(!existsSync(builtHost))('built Desktop Host Office resources', (
writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n')
if (mode === 'missing-kit') installSkillProbe(root, paths.profile)
const primaryRuntime = mode === 'relative-source' ? 'runtime/primary-runtime' : join(runtime, 'primary-runtime')
// An ASAR runtime path without its unpacked sibling locates no Kit; the Host itself still runs from the project.
writeFileSync(join(root, 'app.asar'), 'archive fixture')
const office = desktopOfficeEnvironment(mode === 'missing-kit' ? join(root, 'app.asar', 'dsh') : project, primaryRuntime)
expect(office.kind).toBe(mode === 'missing-kit' ? 'kit-unavailable' : 'ready')
if (mode === 'missing-kit') {
const nodeBin = join(primaryRuntime, 'dependencies', 'node', 'bin')
mkdirSync(nodeBin, { recursive: true })
writeFileSync(join(nodeBin, process.platform === 'win32' ? 'node.exe' : 'node'), '')
}
const inherited: NodeJS.ProcessEnv = { ...process.env, DSH_BUNDLED_OFFICE_CLI: join(root, 'stale-cli.js') }
host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined,
{ ...inherited, ...office.environment }, (error) => { failures.push(error) })
{ ...inherited, DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime,
DSH_BUNDLED_RUNTIME_ROOT: mode === 'missing-kit' ? project : undefined }, (error) => { failures.push(error) })
const { url } = await host.start()
// The authenticated URL exchanges its token for a session cookie by redirecting.
const response = await fetch(url, { redirect: 'manual' })
@@ -87,7 +88,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop Host Office resources', (
expect(skills.names.toSorted()).toEqual(OFFICE_SKILLS)
expect(skills.content['office-docx']).toEqual(expect.any(String))
expect(skills.content['office-docx']).not.toContain('LibreOffice Kit is disabled')
expect(skills.content['office-export-pdf']).toMatch(/\n\nLibreOffice Kit is disabled in this deployment\.$/u)
expect(skills.content['office-export-pdf']).toMatch(/\n\nLibreOffice Kit is not available in this deployment\.$/u)
expect(skills.content['office-export-pdf']).not.toContain('libreofficeKit')
}
expect(failures).toEqual([])
+79
View File
@@ -0,0 +1,79 @@
/** Plain-Node release checks resolve Kit from the runtime's skill package and reject incomplete artifacts. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { resolveKitCli } from '@deepseek-ai/dsh-office-kit/locate'
import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
vi.mock('@deepseek-ai/dsh-office-kit/locate', async (original) => {
const actual = await original<typeof import('@deepseek-ai/dsh-office-kit/locate')>()
return { ...actual, resolveKitCli: vi.fn(actual.resolveKitCli) }
})
afterEach(() => { vi.resetAllMocks() })
function fixture(packed: boolean) {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-smoke-')))
onTestFinished(() => { rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }) })
const runtime = join(root, packed ? 'app.asar/dsh' : 'dsh')
const physical = join(root, packed ? 'app.asar.unpacked/dsh' : 'dsh')
if (packed) writeFileSync(join(root, 'app.asar'), 'archive fixture')
mkdirSync(physical, { recursive: true })
return { root, runtime, physical }
}
function installKit(directory: string): string {
const kit = join(directory, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
mkdirSync(join(kit, 'lib'), { recursive: true })
writeFileSync(join(kit, 'package.json'), JSON.stringify({ name: '@deepseek-ai/libreoffice-kit',
exports: { '.': './lib/index.js', './cli': './lib/cli.js' } }))
writeFileSync(join(kit, 'lib', 'index.js'), '')
const cli = join(kit, 'lib', 'cli.js')
writeFileSync(cli, '')
return cli
}
it.each([false, true])('resolves the skill-owned Kit before the runtime smoke (ASAR=%s)', async (packed) => {
const f = fixture(packed)
installKit(f.root)
const cli = installKit(f.physical)
const actual = await vi.importActual<typeof import('@deepseek-ai/dsh-office-kit/locate')>('@deepseek-ai/dsh-office-kit/locate')
const checked = new Error('fixture stops after checking Kit discovery')
vi.mocked(resolveKitCli).mockImplementationOnce((options) => {
expect(options.root).toBe(f.runtime)
expect(actual.resolveKitCli(options)).toEqual({ kind: 'available', node: process.execPath, cli })
throw checked
})
await expect(smokeDesktopRuntime(f.runtime, process.execPath, {} as DesktopRuntimeDescriptor, {}, f.root)).rejects.toBe(checked)
})
it('prefers a Kit nested in skill-office rather than a Host dependency', async () => {
const f = fixture(false)
installKit(join(f.physical, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))
const skill = join(f.physical, 'node_modules', '@deepseek-ai', 'dsh-skill-office')
const cli = installKit(skill)
const actual = await vi.importActual<typeof import('@deepseek-ai/dsh-office-kit/locate')>('@deepseek-ai/dsh-office-kit/locate')
const checked = new Error('fixture stops after checking Kit discovery')
vi.mocked(resolveKitCli).mockImplementationOnce((options) => {
expect(actual.resolveKitCli(options)).toMatchObject({ kind: 'available', cli })
throw checked
})
await expect(smokeDesktopRuntime(f.runtime, process.execPath, {} as DesktopRuntimeDescriptor, {}, f.root)).rejects.toBe(checked)
})
it.each([false, true])('rejects an ancestor Kit outside the runtime (ASAR=%s)', async (packed) => {
const f = fixture(packed)
installKit(f.root)
const error = await smokeDesktopRuntime(f.runtime, process.execPath, {} as DesktopRuntimeDescriptor, {}, f.root)
.then(() => undefined, (reason: unknown) => reason)
expect(error).toHaveProperty('message', 'desktop runtime: LibreOffice Kit CLI is missing from the runtime')
expect(error).toHaveProperty('cause.message', expect.stringContaining('outside root'))
})
it('strictly rejects a disabled CLI resolution', async () => {
const f = fixture(false)
vi.mocked(resolveKitCli).mockReturnValueOnce({ kind: 'disabled' })
await expect(smokeDesktopRuntime(f.runtime, process.execPath, {} as DesktopRuntimeDescriptor, {}, dirname(f.runtime)))
.rejects.toThrow('LibreOffice Kit CLI is missing from the runtime')
})
+1 -2
View File
@@ -10,7 +10,6 @@ import { DesktopHostProcess } from '../src/host-process.ts'
import { desktopClientMetadata } from '../src/client-metadata.ts'
import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts'
import { authenticateWebHost } from '../src/web-document.ts'
import { desktopOfficeEnvironment } from '../src/office-cli.ts'
import { builtHost, prepareDesktopHome, repository } from './harness.ts'
afterEach(() => { vi.unstubAllEnvs() })
@@ -91,7 +90,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
const restart = async (): Promise<void> => {
await host?.stop()
host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined,
{ ...process.env, ...desktopOfficeEnvironment(project, join(root, 'runtime', 'primary-runtime')).environment })
{ ...process.env, DSH_BUNDLED_PRIMARY_RUNTIME: join(root, 'runtime', 'primary-runtime'), DSH_BUNDLED_RUNTIME_ROOT: undefined })
const { url } = await host.start()
hostOrigin = new URL(url).origin
backend = connectDesktopWelcome(url, await authenticateWebHost(url))
+3 -3
View File
@@ -1661,8 +1661,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
...['doc', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
])
const releaseConversion = Promise.withResolvers<undefined>()
const convertOffice = scaffold.ctx.officeToPdf.convert.bind(scaffold.ctx.officeToPdf)
const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert').mockImplementationOnce(async (...args) => {
const convertOffice = scaffold.ctx.officeKit.convert.bind(scaffold.ctx.officeKit)
const convert = vi.spyOn(scaffold.ctx.officeKit, 'convert').mockImplementationOnce(async (...args) => {
await releaseConversion.promise
return convertOffice(...args)
})
@@ -1682,7 +1682,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await successShot(page, `office-loading-${colorScheme}`)
}
const pdfResponse = page.waitForResponse(
response => new URL(response.url()).pathname === '/api/officeToPdf/render',
response => new URL(response.url()).pathname === '/api/officeKit/render',
{ timeout: 60_000 },
)
releaseConversion.resolve(undefined)
+5 -5
View File
@@ -68,8 +68,8 @@ flowchart LR
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_cua["cua"]
svc_cuaAccess["ctx.cuaAccess<br/>Computer Use permission controls"]
pkg_office_to_pdf["office-to-pdf"]
svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
pkg_office_kit["office-kit"]
svc_officeKit["ctx.officeKit<br/>Office to PDF conversion"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
pkg_attachment["attachment"]
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
@@ -405,7 +405,7 @@ flowchart LR
pkg_mcp_manager --> svc_mcpManager
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
pkg_office_kit --> svc_officeKit
pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
@@ -537,7 +537,7 @@ flowchart LR
svc_llm --> pkg_compaction_basic
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
svc_officeKit --> pkg_client_ui_sidebar_documentpreview
svc_otel --> pkg_host_product_telemetry_otel
svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
@@ -669,7 +669,7 @@ flowchart LR
| `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
| `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |
| `ctx.cuaAccess` | `core` | [`cua`](../packages/experimental/cua) | - | [`cua`](../packages/experimental/cua) | - | The native provider owns application consent and cancellation; the optional Client page queries and changes those decisions through its Remote. |
| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
| `ctx.officeKit` | `core` | [`office-kit`](../packages/document/office-kit) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
+5 -5
View File
@@ -70,8 +70,8 @@ flowchart LR
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_cua["cua"]
svc_cuaAccess["ctx.cuaAccess<br/>Computer Use permission controls"]
pkg_office_to_pdf["office-to-pdf"]
svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
pkg_office_kit["office-kit"]
svc_officeKit["ctx.officeKit<br/>Office to PDF conversion"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
pkg_attachment["attachment"]
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
@@ -407,7 +407,7 @@ flowchart LR
pkg_mcp_manager --> svc_mcpManager
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
pkg_office_kit --> svc_officeKit
pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
@@ -539,7 +539,7 @@ flowchart LR
svc_llm --> pkg_compaction_basic
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
svc_officeKit --> pkg_client_ui_sidebar_documentpreview
svc_otel --> pkg_host_product_telemetry_otel
svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
@@ -671,7 +671,7 @@ flowchart LR
| `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
| `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |
| `ctx.cuaAccess` | `core` | [`cua`](../packages/experimental/cua) | - | [`cua`](../packages/experimental/cua) | - | 原生 provider 负责应用授权和取消操作;可选的 Client 权限页面通过 Remote 查询和修改授权。 |
| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
| `ctx.officeKit` | `core` | [`office-kit`](../packages/document/office-kit) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |
+13 -9
View File
@@ -2530,12 +2530,12 @@ export interface Config {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-message-feedback -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-office-to-pdf -->
<a id="deepseek-aidsh-office-to-pdf"></a>
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-office-kit -->
<a id="deepseek-aidsh-office-kit"></a>
## `@deepseek-ai/dsh-office-to-pdf`
## `@deepseek-ai/dsh-office-kit`
- `source`: [`packages/document/office-to-pdf/src/index.ts:32`](../packages/document/office-to-pdf/src/index.ts)
- `source`: [`packages/document/office-kit/src/index.ts:32`](../packages/document/office-kit/src/index.ts)
```ts config-catalog
/** Provider concurrency and kit rendering/font configuration. */
@@ -2580,7 +2580,7 @@ export interface Config {
maxLoadedFontBytes: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-office-to-pdf -->
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-office-kit -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-permission-presets -->
<a id="deepseek-aidsh-permission-presets"></a>
@@ -3422,7 +3422,7 @@ export interface Config {
## `@deepseek-ai/dsh-skill-office`
- `inject`: `skills`
- `source`: [`packages/skill/skill-office/src/index.ts:40`](../packages/skill/skill-office/src/index.ts)
- `source`: [`packages/skill/skill-office/src/index.ts:46`](../packages/skill/skill-office/src/index.ts)
```ts config-catalog
/** Configuration after schema defaults apply. */
@@ -3437,9 +3437,14 @@ export interface Config {
assetRoot?: string
/** Standalone Node executable; defaults to the current executable outside Electron. */
node?: string
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access, and omission uses the CLI of this package's Kit. */
cli?: string | false
/** Python environment named in the Python authoring and verification skills. @default 'user' */
/**
* Absolute directory restricting automatic Kit entry and CLI discovery after realpath and ASAR-unpacked mapping.
* Omission leaves discovery unrestricted; explicit `cli` bypasses the restriction.
*/
kitRoot?: string
/** Python environment named in the Python authoring and verification skills. @default 'workspace-dependencies' */
pythonEnvironment?: PythonEnvironment
}
@@ -5100,7 +5105,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) |
| `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) |
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-office-engine-resolver` | — | [`packages/document/office-engine-resolver/src/index.ts`](../packages/document/office-engine-resolver/src/index.ts) |
| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
+13 -9
View File
@@ -2532,12 +2532,12 @@ export interface Config {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-message-feedback -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-office-to-pdf -->
<a id="deepseek-aidsh-office-to-pdf"></a>
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-office-kit -->
<a id="deepseek-aidsh-office-kit"></a>
## `@deepseek-ai/dsh-office-to-pdf`
## `@deepseek-ai/dsh-office-kit`
- `source`: [`packages/document/office-to-pdf/src/index.ts:32`](../packages/document/office-to-pdf/src/index.ts)
- `source`: [`packages/document/office-kit/src/index.ts:32`](../packages/document/office-kit/src/index.ts)
```ts config-catalog
/** Provider concurrency and kit rendering/font configuration. */
@@ -2582,7 +2582,7 @@ export interface Config {
maxLoadedFontBytes: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-office-to-pdf -->
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-office-kit -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-permission-presets -->
<a id="deepseek-aidsh-permission-presets"></a>
@@ -3424,7 +3424,7 @@ export interface Config {
## `@deepseek-ai/dsh-skill-office`
- `inject`: `skills`
- `source`: [`packages/skill/skill-office/src/index.ts:40`](../packages/skill/skill-office/src/index.ts)
- `source`: [`packages/skill/skill-office/src/index.ts:46`](../packages/skill/skill-office/src/index.ts)
```ts config-catalog
/** Configuration after schema defaults apply. */
@@ -3439,9 +3439,14 @@ export interface Config {
assetRoot?: string
/** Standalone Node executable; defaults to the current executable outside Electron. */
node?: string
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access, and omission uses the CLI of this package's Kit. */
cli?: string | false
/** Python environment named in the Python authoring and verification skills. @default 'user' */
/**
* Absolute directory restricting automatic Kit entry and CLI discovery after realpath and ASAR-unpacked mapping.
* Omission leaves discovery unrestricted; explicit `cli` bypasses the restriction.
*/
kitRoot?: string
/** Python environment named in the Python authoring and verification skills. @default 'workspace-dependencies' */
pythonEnvironment?: PythonEnvironment
}
@@ -5102,7 +5107,6 @@ export interface Config {
| `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) |
| `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) |
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-office-engine-resolver` | — | [`packages/document/office-engine-resolver/src/index.ts`](../packages/document/office-engine-resolver/src/index.ts) |
| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
@@ -18,8 +18,8 @@
en: e68bfcde70f845b4
zh: 73e5c4fb067c4fa1
/package-surface-rules/internal-dependency-syntax-follows-the-package-family:
en: 9e69af33363f4231
zh: 2deaf03d7856f92d
en: b20f0e2a69ecbe45
zh: 678ceec7239517aa
/package-surface-rules/a-new-resolve-hook-is-refused-outright:
en: b420ae3e228432d5
zh: 0c49c4a9c3a5262c
+1 -1
View File
@@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page
Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` use `workspace:*`, every other internal package uses `workspace:~`, and `workspace:^` has retired. The same dependency name is written the same way in every workspace, and a mismatch is a hard scan failure rather than a warning a reviewer can waive. The uniform syntax keeps one resolution path and prevents duplicate instances of a dependency and the resolution differences that follow them.
- Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name.
- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `0.2.3` in `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies).
- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `0.2.3` in `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-kit/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies).
- Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule.
- Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change.
- Recompute:
@@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page
内部依赖写法按包族决定:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 用 `workspace:*`,其余内部包用 `workspace:~`,`workspace:^` 已退出。同一个依赖名在所有 workspace 内写法一致,不一致是扫描硬失败,不是评审可以放行的警告。写法统一才能保持一条解析路径,避免同一个依赖出现重复实例以及随之而来的解析差异。
- 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。
- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `0.2.3`。
- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-kit/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `0.2.3`。
- 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。
- 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。
- 复算:
+2 -4
View File
@@ -252,8 +252,7 @@ flowchart TD
pkg_workspace_changes["workspace-changes"]
end
subgraph group_document["packages/document"]
pkg_office_engine_resolver["office-engine-resolver"]
pkg_office_to_pdf["office-to-pdf"]
pkg_office_kit["office-kit"]
end
subgraph group_experimental["packages/experimental"]
pkg_action_cards["action-cards"]
@@ -1620,8 +1619,7 @@ flowchart TD
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — |
| [`client-web`](../packages/client/web) | `client` | — |
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
| [`office-engine-resolver`](../packages/document/office-engine-resolver) | `document` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`office-kit`](../packages/document/office-kit) | `document` | — |
| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — |
| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — |
| [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — |
+2 -4
View File
@@ -254,8 +254,7 @@ flowchart TD
pkg_workspace_changes["workspace-changes"]
end
subgraph group_document["packages/document"]
pkg_office_engine_resolver["office-engine-resolver"]
pkg_office_to_pdf["office-to-pdf"]
pkg_office_kit["office-kit"]
end
subgraph group_experimental["packages/experimental"]
pkg_action_cards["action-cards"]
@@ -1622,8 +1621,7 @@ flowchart TD
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — |
| [`client-web`](../packages/client/web) | `client` | — |
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
| [`office-engine-resolver`](../packages/document/office-engine-resolver) | `document` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`office-kit`](../packages/document/office-kit) | `document` | — |
| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — |
| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — |
| [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — |
+2 -2
View File
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
en: e95b837b8f47eac8
zh: bd69de97c80fe2ae
en: 4cf956a938fd0e1e
zh: 6f9cddde7d522f9b
+1 -1
View File
@@ -33,7 +33,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership |
| [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts |
| [office-to-pdf.md](office-to-pdf.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse |
| [office-kit.md](office-kit.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse |
| [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam |
| [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles |
| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary |
+1 -1
View File
@@ -33,7 +33,7 @@
| [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 |
| [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 |
| [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 |
| [office-to-pdf.md](office-to-pdf.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 |
| [office-kit.md](office-kit.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 |
| [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam |
| [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 |
| [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 |
+19
View File
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for office-kit.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/office-kit.md
/office-kit:
en: 2b744f184e91b28d
zh: 4e451775d973a798
/office-kit/ownership:
en: 6c92cc93e73f5035
zh: e34fd9017be4af73
/office-kit/requests-and-results:
en: bf8b87193b9cf9ed
zh: 152dd4a4b5d4fc8d
/office-kit/preview-reads:
en: 3a373dfc9dd945c3
zh: ff5f9a16d0e7b96f
/office-kit/engine-selection-and-limits:
en: 3ebbb7096d8e87b0
zh: 0a08192121ef3cad
@@ -1,6 +1,6 @@
# Office to PDF
# Office Kit
English | [中文](office-to-pdf.zh.md)
English | [中文](office-kit.zh.md)
The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event.
@@ -8,16 +8,15 @@ The [document package family](../../packages/document/README.md) converts Office
| Owner | Responsibility |
|---|---|
| [office-engine-resolver](../../packages/document/office-engine-resolver/README.md) | Resolve engine manifests from the caller's Kit installation and map native paths to ASAR unpacked directories |
| [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching |
| [office-kit](../../packages/document/office-kit/README.md) | `ctx.officeKit`: shared LibreOffice conversion, bounded admission, and PDF caching; `/locate`: the caller's Kit entry and CLI, engine manifests from that installation, and native paths mapped to ASAR unpacked directories |
| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers |
| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices |
## Requests and results
[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `OfficeExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `OfficeToPdf.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `OfficeToPdfError`.
[`OfficeKitRequest`](../../packages/document/office-kit/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `OfficeExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `OfficeKit.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `OfficeKitError`.
`OfficeToPdfPriority` is `foreground` for requested preview/QA and `background` for speculation. `OfficeSourceKey` brands the caller-owned authorized source locator. `OfficeToPdfGeneration` brands a provider lifetime, and `OfficeToPdfKey` brands its content identity; neither opaque value is parsed by consumers.
`OfficeKitPriority` is `foreground` for requested preview/QA and `background` for speculation. `OfficeSourceKey` brands the caller-owned authorized source locator. `OfficeKitGeneration` brands a provider lifetime, and `OfficeKitKey` brands its content identity; neither opaque value is parsed by consumers.
| Result field | Meaning |
|---|---|
@@ -32,15 +31,15 @@ The provider admits the deferred read before allocating source bytes, shares con
`RenderedDocumentBytes` carries workspace file metadata, native PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF.
The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
The `officeKit.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance.
## Engine selection and limits
The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine reference](../../packages/document/office-to-pdf/README.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC.
The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine reference](../../packages/document/office-kit/README.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. When the provider's Kit is absent or fails to import, the provider still registers and rejects conversions as `unavailable`. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC.
The [Host provider configuration](../../packages/document/office-to-pdf/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider.
The [Host provider configuration](../../packages/document/office-kit/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -50,9 +49,9 @@ The [Host provider configuration](../../packages/document/office-to-pdf/README.m
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxofficetopdf--officetopdf"></a>
<a id="ctxofficekit--officekit"></a>
### `ctx.officeToPdf` — `OfficeToPdf`
### `ctx.officeKit` — `OfficeKit`
A provider lifetime owns all converters, queued calls, and temporary files.
@@ -62,9 +61,9 @@ A provider lifetime owns all converters, queued calls, and temporary files.
* @param request - authorized metadata and deferred bounded source read.
* @param signal - caller cancellation; provider disposal also stops active work.
* @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.
* @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
* @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
*/
convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
convert(request: OfficeKitRequest, signal?: AbortSignal): Promise<OfficeKitResult>
/**
* Read and convert one Office file using the Session's ordinary filesystem authorization.
@@ -74,15 +73,15 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfR
* @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
* @returns complete PDF bytes with original source identity and missing font families.
*/
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeKitPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
/**
* Read the current rendering generation before reusing a Client PDF.
* @param signal - Remote caller cancellation.
* @returns provider lifetime, replaced with rendering, font, or engine configuration.
*/
@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
@Remote('generation') getGeneration(signal: AbortSignal): OfficeKitGeneration
```
Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)
Source: [`packages/document/office-kit/src/index.ts`](../../packages/document/office-kit/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -1,6 +1,6 @@
# Office 转 PDF
# Office Kit
[English](office-to-pdf.md) | 中文
[English](office-kit.md) | 中文
[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。
@@ -8,16 +8,15 @@
| 所有者 | 职责 |
|---|---|
| [office-engine-resolver](../../packages/document/office-engine-resolver/README.zh.md) | 从调用方的 Kit 安装定位引擎清单,并将原生路径映射到 ASAR 解包目录 |
| [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 |
| [office-kit](../../packages/document/office-kit/README.zh.md) | `ctx.officeKit`:共享 LibreOffice 转换、有界准入和 PDF 缓存;`/locate`:调用方的 Kit 入口和 CLI、从该安装定位的引擎清单,以及映射到 ASAR 解包目录的原生路径 |
| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 |
| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 |
## 请求和结果
[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `OfficeExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`OfficeToPdf.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `OfficeToPdfError` 拒绝。
[`OfficeKitRequest`](../../packages/document/office-kit/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `OfficeExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`OfficeKit.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `OfficeKitError` 拒绝。
`OfficeToPdfPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeToPdfGeneration` 表示提供方生命周期,`OfficeToPdfKey` 表示其内容身份;消费者不解析这两种不透明值。
`OfficeKitPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeKitGeneration` 表示提供方生命周期,`OfficeKitKey` 表示其内容身份;消费者不解析这两种不透明值。
| 结果字段 | 含义 |
|---|---|
@@ -32,15 +31,15 @@
`RenderedDocumentBytes` 携带工作区文件元数据、原生 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。
`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
`officeKit.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。
## 引擎选择和限制
外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎参考](../../packages/document/office-to-pdf/README.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。
外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎参考](../../packages/document/office-kit/README.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。提供方的 Kit 缺失或导入失败时,提供方仍会注册,并以 `unavailable` 拒绝转换。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。
[Host 提供方配置](../../packages/document/office-to-pdf/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。
[Host 提供方配置](../../packages/document/office-kit/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -50,9 +49,9 @@
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxofficetopdf--officetopdf"></a>
<a id="ctxofficekit--officekit"></a>
### `ctx.officeToPdf` — `OfficeToPdf`
### `ctx.officeKit` — `OfficeKit`
A provider lifetime owns all converters, queued calls, and temporary files.
@@ -62,9 +61,9 @@ A provider lifetime owns all converters, queued calls, and temporary files.
* @param request - authorized metadata and deferred bounded source read.
* @param signal - caller cancellation; provider disposal also stops active work.
* @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.
* @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
* @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
*/
convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
convert(request: OfficeKitRequest, signal?: AbortSignal): Promise<OfficeKitResult>
/**
* Read and convert one Office file using the Session's ordinary filesystem authorization.
@@ -74,15 +73,15 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfR
* @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
* @returns complete PDF bytes with original source identity and missing font families.
*/
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeKitPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
/**
* Read the current rendering generation before reusing a Client PDF.
* @param signal - Remote caller cancellation.
* @returns provider lifetime, replaced with rendering, font, or engine configuration.
*/
@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
@Remote('generation') getGeneration(signal: AbortSignal): OfficeKitGeneration
```
Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)
Source: [`packages/document/office-kit/src/index.ts`](../../packages/document/office-kit/src/index.ts)
<!-- END GENERATED cordis-surface -->
-19
View File
@@ -1,19 +0,0 @@
# Bilingual-pair consistency record for office-to-pdf.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md
/office-to-pdf:
en: 9a24c6158f9fb214
zh: 773155d0e59fffe2
/office-to-pdf/ownership:
en: e8ec782434854dfe
zh: efda9390e8b8e3a0
/office-to-pdf/requests-and-results:
en: 8a1fa9eb6e82ef8f
zh: e263ca49c5c957d6
/office-to-pdf/preview-reads:
en: 715105e6fff89e7c
zh: a7fe63c7dd0be17f
/office-to-pdf/engine-selection-and-limits:
en: 3908dab1b7d78890
zh: 8f2aed6e27c5b5dd
+2 -2
View File
@@ -21,8 +21,8 @@
en: 6b5faaed58ab5fb1
zh: 2118439d3403a735
/right-sidebar/document-renderers:
en: 2bf47a890fce175e
zh: 45f81c579b8939a8
en: 5e7e08977a598765
zh: 73181ef0cbf8e62b
/right-sidebar/resource-model:
en: 2916306e6cb9282c
zh: cdec047989860202
+1 -1
View File
@@ -123,7 +123,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 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.
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-kit.md); its face writes converted bytes, font metadata, and failures to its own store, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
## Resource model
+1 -1
View File
@@ -123,7 +123,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 }`,而不是文件字节。正文调用自己的注入 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)定义加载生命周期。
自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文调用自己的注入 face,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-kit.zh.md);其 face 将转换字节、字体元数据和失败写入自己的 store,正文渲染嵌套 PDF 视图。`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
## 资源模型
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/office-kit-package/guide.md
/:
en: 0e3fae218b0fe86f
zh: 31fa1f4ac5cfffe6
/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf:
en: 905ef2a0e635d288
zh: 7a661f303ee0f2bb
/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf/change:
en: e263b63bfde416ac
zh: 3b3b0002fa96f15e
/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf/migration:
en: cfbb9717f7017504
zh: 91a6073d3bf06db1
@@ -0,0 +1,37 @@
---
kind: upgrade-guide
description: "`@deepseek-ai/dsh-office-kit` replaces `@deepseek-ai/dsh-office-to-pdf`, renaming its service, Remote methods, exported types, and configuration row."
---
# `@deepseek-ai/dsh-office-kit` replaces `@deepseek-ai/dsh-office-to-pdf`
English | [中文](guide.zh.md)
## Change
The Office conversion provider moved from `@deepseek-ai/dsh-office-to-pdf` to `@deepseek-ai/dsh-office-kit`. The new package also exports LibreOffice Kit location functions from `@deepseek-ai/dsh-office-kit/locate`. Compositions, plugins, and Clients that name the old package, service, Remote methods, or exported symbols are affected:
| Old | New |
|---|---|
| `cordis.yml` row `id: office-to-pdf`, `name: '@deepseek-ai/dsh-office-to-pdf'` | `id: office-kit`, `name: '@deepseek-ai/dsh-office-kit'`; configuration keys are unchanged |
| `ctx.officeToPdf` | `ctx.officeKit` |
| Remote `officeToPdf/render`, `officeToPdf/generation` | `officeKit/render`, `officeKit/generation` |
| `OfficeToPdf` | `OfficeKit` |
| `OfficeToPdfError`, `OfficeToPdfErrorCode` | `OfficeKitError`, `OfficeKitErrorCode` |
| `OfficeToPdfGeneration` | `OfficeKitGeneration` |
| `OfficeToPdfRequest`, `OfficeToPdfResult`, `OfficeToPdfPriority`, `OfficeToPdfKey` | `OfficeKitRequest`, `OfficeKitResult`, `OfficeKitPriority`, `OfficeKitKey` |
The `document-render/failed` Remote error, `RenderedDocumentBytes`, `OfficeSourceKey`, and `OfficeExtension` keep their names. The `/types` and `/remote` entries remain.
The provider previously failed to load when LibreOffice Kit was absent. It now registers without Kit; each conversion that needs a converter rejects with `OfficeKitError` code `unavailable` and locates Kit again, so installing Kit later takes effect without reloading the provider.
Desktop resolves its Kit CLI inside the Host and does not set or read `DSH_BUNDLED_OFFICE_CLI`. Operation skills report an unavailable Kit through their instructions and a warning, without a separate diagnostic file. Custom Desktop patches can constrain automatic discovery with the absolute `kitRoot` directory; explicit `cli` paths bypass that restriction.
## Migration
1. In `cordis.yml`, patches, and overlays, change the row `id: office-to-pdf` to `id: office-kit` and its `name` to `'@deepseek-ai/dsh-office-kit'`. Keep its `config` block unchanged.
2. Replace the dependency `@deepseek-ai/dsh-office-to-pdf` with `@deepseek-ai/dsh-office-kit` in `package.json`, and update import specifiers, including `/types` and `/remote`.
3. Rename `ctx.officeToPdf`, Remote method names, and the exported symbols listed above.
4. Code that treated a load failure of the conversion row as "Kit absent" now checks for `OfficeKitError` with code `unavailable` from conversion instead.
5. Confirm the migration by searching the project for `office-to-pdf`, `officeToPdf`, and `OfficeToPdf`, then opening a DOCX file in the Web document preview.
6. Remove Desktop patches that read `DSH_BUNDLED_OFFICE_CLI`; use the shipped automatic discovery, or set an absolute `cli` explicitly. Load `office-export-pdf` to confirm it supplies the expected CLI paths or reports that Kit is unavailable.
@@ -0,0 +1,37 @@
---
kind: upgrade-guide
description: "`@deepseek-ai/dsh-office-kit` 取代 `@deepseek-ai/dsh-office-to-pdf`,其服务、Remote 方法、导出类型和配置行随之改名。"
---
# `@deepseek-ai/dsh-office-kit` 取代 `@deepseek-ai/dsh-office-to-pdf`
[English](guide.md) | 中文
## 变更
Office 转换提供方从 `@deepseek-ai/dsh-office-to-pdf` 移到 `@deepseek-ai/dsh-office-kit`。新包还从 `@deepseek-ai/dsh-office-kit/locate` 导出 LibreOffice Kit 定位函数。引用旧包、服务、Remote 方法或导出符号的组合、插件和 Client 受到影响:
| 旧名 | 新名 |
|---|---|
| `cordis.yml` 行 `id: office-to-pdf`、`name: '@deepseek-ai/dsh-office-to-pdf'` | `id: office-kit`、`name: '@deepseek-ai/dsh-office-kit'`;配置项不变 |
| `ctx.officeToPdf` | `ctx.officeKit` |
| Remote `officeToPdf/render`、`officeToPdf/generation` | `officeKit/render`、`officeKit/generation` |
| `OfficeToPdf` | `OfficeKit` |
| `OfficeToPdfError`、`OfficeToPdfErrorCode` | `OfficeKitError`、`OfficeKitErrorCode` |
| `OfficeToPdfGeneration` | `OfficeKitGeneration` |
| `OfficeToPdfRequest`、`OfficeToPdfResult`、`OfficeToPdfPriority`、`OfficeToPdfKey` | `OfficeKitRequest`、`OfficeKitResult`、`OfficeKitPriority`、`OfficeKitKey` |
Remote 错误 `document-render/failed`、`RenderedDocumentBytes`、`OfficeSourceKey` 和 `OfficeExtension` 保持原名。`/types` 和 `/remote` 入口仍然保留。
原先 LibreOffice Kit 缺失时提供方加载失败。现在没有 Kit 时提供方照常注册;每次需要转换器的转换都以代码为 `unavailable` 的 `OfficeKitError` 拒绝,并重新定位 Kit,因此之后安装的 Kit 无需重新加载提供方即可生效。
Desktop 在 Host 内解析 Kit CLI,不设置也不读取 `DSH_BUNDLED_OFFICE_CLI`。操作技能通过说明和警告报告 Kit 不可用,不写独立诊断文件。自定义 Desktop patch 可使用绝对目录 `kitRoot` 限制自动查找;显式 `cli` 路径不受此限制。
## 迁移
1. 在 `cordis.yml`、patch 和 overlay 中,把 `id: office-to-pdf` 行改为 `id: office-kit`,并把其 `name` 改为 `'@deepseek-ai/dsh-office-kit'`。`config` 块保持不变。
2. 在 `package.json` 中用 `@deepseek-ai/dsh-office-kit` 替换依赖 `@deepseek-ai/dsh-office-to-pdf`,并更新导入路径,包括 `/types` 和 `/remote`。
3. 按上表重命名 `ctx.officeToPdf`、Remote 方法名和导出符号。
4. 原先把转换行加载失败视为“Kit 缺失”的代码,改为检查转换返回的、代码为 `unavailable` 的 `OfficeKitError`。
5. 在项目中搜索 `office-to-pdf`、`officeToPdf` 和 `OfficeToPdf` 确认已无残留,然后在 Web 文档预览中打开一个 DOCX 文件,确认迁移完成。
6. 移除读取 `DSH_BUNDLED_OFFICE_CLI` 的 Desktop patch;使用随包提供的自动查找,或显式设置绝对 `cli`。加载 `office-export-pdf`,确认其提供预期 CLI 路径或报告 Kit 不可用。
+1 -1
View File
@@ -96,7 +96,7 @@
"@deepseek-ai/dsh-mcp-manager": "workspace:*",
"@deepseek-ai/dsh-plugin-market": "workspace:*",
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
"@deepseek-ai/dsh-office-kit": "workspace:*",
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
+3 -3
View File
@@ -10,7 +10,7 @@ import agentTeamsRemote from '@deepseek-ai/dsh-agent-team/remote'
export type {} from '@deepseek-ai/dsh-agent-team/remote'
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote'
import officeKitRemote from '@deepseek-ai/dsh-office-kit/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import scheduleRemote from '@deepseek-ai/dsh-schedule/remote'
import llmRemote from '@deepseek-ai/dsh-llm/remote'
@@ -63,7 +63,7 @@ export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
export type {} from '@deepseek-ai/dsh-api-account-controller/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-schedule/remote'
export type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
export type {} from '@deepseek-ai/dsh-office-kit/remote'
export type {} from '@deepseek-ai/dsh-llm/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
@@ -204,7 +204,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
messageFeedbackRemote, sessionFeedbackRemote,
fileUploadsRemote, sessionReferencesRemote,
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
officeToPdfRemote, userQuestionsRemote, webRemote,
officeKitRemote, userQuestionsRemote, webRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
}
+1 -1
View File
@@ -110,7 +110,7 @@
"path": "../../boot/plugin-market"
},
{
"path": "../../document/office-to-pdf"
"path": "../../document/office-kit"
},
{
"path": "../account-controller"
+1 -1
View File
@@ -47,7 +47,7 @@
assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? ((process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME, '..', 'office-skills') : undefined)"
cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI"
node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? ((process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') : undefined)"
pythonEnvironment: !!js "(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? 'workspace-dependencies' : 'user'"
pythonEnvironment: !!js "(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? undefined : 'user'"
# Share native desktop tools and model context without starting a Web server.
- insert:
+4 -4
View File
@@ -33,8 +33,8 @@
en: fdf38cb6e1b24464
zh: c14a8bcac9d4e5cd
/deepseek-ai-dsh-web-app/understand-the-implementation:
en: dd31d413ea2bf674
zh: d2954dea99688950
en: 27944b34d6a7410e
zh: f37f8ec95a5a5809
/deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics:
en: a6d126f30b1594d7
zh: 7f0ccc8a55d6f743
@@ -75,8 +75,8 @@
en: e9ca0baef5de7a6e
zh: affb091495596cde
/deepseek-ai-dsh-web-app/model-experience/office-skills/kv-cache-effect:
en: ed2a95af7d249af3
zh: 77b450153e8cec5f
en: 43f28f4e120df7bb
zh: 3b4e0d384986b293
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work:
en: 635c1336e2f4e821
zh: caabdecb02e2df14
+3 -3
View File
@@ -100,9 +100,9 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
<details>
<summary>Implementation internals — click to expand</summary>
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-kit` row mounts one lazy [Office conversion provider](../../document/office-kit/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
The `skill-office` row mounts the [Office skills](../../skill/skill-office/README.md) in every profile. Outside Desktop it uses the packaged assets, the current Node executable, the package's Kit CLI, and `pythonEnvironment: 'user'`. Desktop also mounts the bundled dependency-query row, configures the skills from the carrier's paths, and selects `pythonEnvironment: 'workspace-dependencies'`; other profiles leave the dependency-query row disabled. On Desktop an absent `DSH_BUNDLED_OFFICE_CLI` selects `cli: false`, retaining readable skills without automatically resolving another Kit installation. The [Desktop application](../../../apps/desktop/README.md#bundled-workspace-dependencies) owns resource resolution and its diagnostics.
The `skill-office` row mounts the [Office skills](../../skill/skill-office/README.md) in every profile. Outside Desktop it selects `pythonEnvironment: 'user'` and reads `DSH_BUNDLED_OFFICE_SKILLS`, `DSH_BUNDLED_OFFICE_NODE`, and `DSH_BUNDLED_OFFICE_CLI`, which only the [Python SDK launcher](../../../python/sdk-runtime/README.md) sets; an empty CLI value selects `cli: false`, and an unset variable keeps the packaged assets, the current Node executable, or the package's Kit CLI. Desktop also mounts the bundled dependency-query row, configures the skills from the carrier's paths, and keeps the default `pythonEnvironment: 'workspace-dependencies'`; other profiles leave the dependency-query row disabled. Desktop omits `cli`, ignoring `DSH_BUNDLED_OFFICE_CLI`. Its `kitRoot` reads `DSH_BUNDLED_RUNTIME_ROOT`, which the shell sets to the dsh runtime directory only for production runtimes, including ASAR and copied release trees. Development leaves the variable unset and `kitRoot` undefined so workspace links can resolve outside the runtime. When a root is supplied, `office-kit/locate` confines automatic CLI discovery to it after ASAR-unpacked mapping and realpath; missing Kit is reported by the skill provider. The [Desktop application](../../../apps/desktop/README.md#bundled-workspace-dependencies) owns resource packaging.
### Patch semantics
@@ -176,7 +176,7 @@ The seven catalog summaries in each initial catalog; loaded skill bodies only wh
#### KV Cache effect
The catalog and each Python environment section are fixed for a profile and do not vary between requests.
While resources and configuration stay unchanged, the built-in Office summaries and Python environment sections stay stable; the [Office skills](../../skill/skill-office/README.md#model-experience) describe when the catalog changes.
## Known Limitations and Deferred Work
+3 -3
View File
@@ -100,9 +100,9 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
<details>
<summary>实现细节——点击展开</summary>
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-kit` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-kit/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
`skill-office` 行在所有 profile 中挂载 [Office 技能](../../skill/skill-office/README.zh.md)。Desktop 之外使用包内 assets、当前 Node 可执行文件、包内 Kit CLI 和 `pythonEnvironment: 'user'`。Desktop 还挂载内置依赖查询行,用载体路径配置技能,并选择 `pythonEnvironment: 'workspace-dependencies'`;其他 profile 保持依赖查询行禁用。在 Desktop 上未提供 `DSH_BUNDLED_OFFICE_CLI` 时使用 `cli: false`,保留可读技能,不自动解析其他 Kit 安装。[Desktop 应用](../../../apps/desktop/README.zh.md#bundled-workspace-dependencies)负责资源定位及其诊断。
`skill-office` 行在所有 profile 中挂载 [Office 技能](../../skill/skill-office/README.zh.md)。Desktop 之外选择 `pythonEnvironment: 'user'`,并读取 `DSH_BUNDLED_OFFICE_SKILLS`、`DSH_BUNDLED_OFFICE_NODE` 和 `DSH_BUNDLED_OFFICE_CLI`;只有 [Python SDK 启动器](../../../python/sdk-runtime/README.zh.md)会设置这些变量。CLI 值为空时使用 `cli: false`,变量未设置时保留包内 assets、当前 Node 可执行文件或包内 Kit CLI。Desktop 还挂载内置依赖查询行,用载体路径配置技能,并保留默认的 `pythonEnvironment: 'workspace-dependencies'`;其他 profile 保持依赖查询行禁用。Desktop 省略 `cli`,不读取 `DSH_BUNDLED_OFFICE_CLI`。其 `kitRoot` 读取 `DSH_BUNDLED_RUNTIME_ROOT`;壳仅为生产运行时设置该变量,值为 dsh 运行时目录,包括 ASAR 和真实复制的 release 树。开发运行时不设置该变量,`kitRoot` 为 undefined,允许工作区链接解析到运行时外。提供 root 时,`office-kit/locate` 在 ASAR 解包映射和 realpath 后,把自动 CLI 查找限制在该根目录内;Kit 缺失由技能提供方报告。[Desktop 应用](../../../apps/desktop/README.zh.md#bundled-workspace-dependencies)负责资源打包。
### patch 语义
@@ -176,7 +176,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
#### KV Cache 影响
目录和各 Python 环境说明对同一 profile 固定,不随请求变化。
资源和配置不变时,内置 Office 摘要和各 Python 环境说明保持稳定;目录何时变化见 [Office 技能](../../skill/skill-office/README.zh.md#model-experience)。
## 已知限制与延期工作
+14 -15
View File
@@ -288,18 +288,16 @@
name: '@deepseek-ai/dsh-client-ui-sidebar-right'
- id: office-to-pdf
name: '@deepseek-ai/dsh-office-to-pdf'
- id: office-kit
name: '@deepseek-ai/dsh-office-kit'
# Office skills load in every Web application profile. Only Desktop mounts
# the bundled Python environment (workspace-dependencies) and sets its
# carrier paths before boot; a row that fails to load leaves the rest of the
# Host running. When the Desktop shell cannot locate its bundled Kit CLI,
# `cli: false` keeps the skills without CLI paths instead of resolving
# another Kit. Outside Desktop the path expressions evaluate to
# `undefined`, which the skill-office schema treats as unset, so the
# packaged assets, the current Node executable, and the package's Kit CLI
# apply, and the skills direct the model to the user's Python.
# Desktop supplies standalone Node through its primary runtime. Production
# shells set DSH_BUNDLED_RUNTIME_ROOT to confine automatic Kit discovery;
# development leaves it unset so workspace links can resolve outside the runtime.
# An unavailable Kit leaves readable skills registered; skill-office owns its warning.
# Outside Desktop, Python SDK launcher variables select resources and an
# empty DSH_BUNDLED_OFFICE_CLI disables CLI access. Unset variables preserve
# packaged defaults; these profiles direct the model to the user's Python.
- id: workspace-dependencies
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
@@ -310,10 +308,11 @@
- id: skill-office
name: '@deepseek-ai/dsh-skill-office'
config:
assetRoot: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, '..', 'office-skills') : undefined"
node: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') : undefined"
cli: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.env.DSH_BUNDLED_OFFICE_CLI ?? false : undefined"
pythonEnvironment: !!js "ctx.get('profileContext')?.name === 'desktop' ? 'workspace-dependencies' : 'user'"
assetRoot: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, '..', 'office-skills') : process.env.DSH_BUNDLED_OFFICE_SKILLS"
node: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') : process.env.DSH_BUNDLED_OFFICE_NODE"
kitRoot: !!js "ctx.get('profileContext')?.name === 'desktop' ? process.env.DSH_BUNDLED_RUNTIME_ROOT : undefined"
cli: !!js "ctx.get('profileContext')?.name === 'desktop' ? undefined : process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI"
pythonEnvironment: !!js "ctx.get('profileContext')?.name === 'desktop' ? undefined : 'user'"
# The right Sidebar's document tab: bounded file reads with selectable
# Markdown, code, HTML, PDF, Office, and plain-text renderers.
+1 -1
View File
@@ -152,7 +152,7 @@
"@deepseek-ai/dsh-launch-environment": "workspace:*",
"@deepseek-ai/dsh-mcp-manager": "workspace:*",
"@deepseek-ai/dsh-message-feedback": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
"@deepseek-ai/dsh-office-kit": "workspace:*",
"@deepseek-ai/dsh-persona": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
@@ -1,13 +1,15 @@
/** Web application Office rows over a real Loader tree: carrier paths, profile gating, Python environments, and failure containment. */
import { cp, mkdtemp, rm } from 'node:fs/promises'
import { cp, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { dirname, join, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
import { resolveKitCli } from '@deepseek-ai/dsh-office-kit/locate'
import SkillRegistry from '@deepseek-ai/dsh-skill'
import * as SkillOffice from '@deepseek-ai/dsh-skill-office'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -15,23 +17,64 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as WorkspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
// Only the consumer module location differs from a deployed runtime; keep resolution and containment real.
vi.mock('@deepseek-ai/dsh-office-kit/locate', async (original) => {
const actual = await original<typeof import('@deepseek-ai/dsh-office-kit/locate')>()
return { ...actual, resolveKitCli: vi.fn(actual.resolveKitCli) }
})
const assets = fileURLToPath(new URL('../../../skill/skill-office/assets/', import.meta.url))
const names = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']
afterEach(() => { vi.unstubAllEnvs() })
afterEach(() => { vi.unstubAllEnvs(); vi.mocked(resolveKitCli).mockReset() })
async function boot(profile: string, primaryRuntime: (root: string) => string | undefined,
officeCli: (root: string) => string | undefined = root => join(root, 'office-cli.js')) {
const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-office-rows-'))
officeCli: (root: string) => string | undefined = root => join(root, 'office-cli.js'),
launcher: (root: string) => { skills?: string; node?: string } = () => ({}),
layout: 'plain' | 'asar' | 'missing' | 'development' = 'plain',
runtimeRoot: (runtime: string) => string | undefined = runtime => runtime) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-desktop-office-rows-')))
onTestFinished(() => rm(root, { recursive: true, force: true }))
vi.stubEnv('DSH_HOME', join(root, 'home'))
vi.stubEnv('DSH_BUNDLED_PRIMARY_RUNTIME', primaryRuntime(root))
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', officeCli(root))
const { skills, node } = launcher(root)
vi.stubEnv('DSH_BUNDLED_OFFICE_SKILLS', skills)
vi.stubEnv('DSH_BUNDLED_OFFICE_NODE', node)
await cp(assets, join(root, 'office-skills'), { recursive: true })
const runtime = join(root, layout === 'asar' ? 'app.asar/dsh' : 'dsh')
await mkdir(runtime, { recursive: true })
vi.stubEnv('DSH_BUNDLED_RUNTIME_ROOT', runtimeRoot(runtime))
const physical = join(root, layout === 'asar' ? 'app.asar.unpacked/dsh' : layout === 'development' ? 'node_modules/.pnpm/kit' : 'dsh')
const cli = join(physical, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib', 'cli.js')
if (layout !== 'missing') {
for (const directory of new Set(layout === 'development' ? [physical] : [runtime, physical])) {
const kit = join(directory, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
await mkdir(join(kit, 'lib'), { recursive: true })
await writeFile(join(kit, 'package.json'), JSON.stringify({ name: '@deepseek-ai/libreoffice-kit',
exports: { '.': './lib/index.js', './cli': './lib/cli.js' } }))
await writeFile(join(kit, 'lib', 'index.js'), '')
await writeFile(join(kit, 'lib', 'cli.js'), '')
}
}
if (layout === 'development') {
const scope = join(runtime, 'node_modules', '@deepseek-ai')
await mkdir(scope, { recursive: true })
await symlink(join(physical, 'node_modules', '@deepseek-ai', 'libreoffice-kit'), join(scope, 'libreoffice-kit'), 'junction')
}
const actual = await vi.importActual<typeof import('@deepseek-ai/dsh-office-kit/locate')>('@deepseek-ai/dsh-office-kit/locate')
vi.mocked(resolveKitCli).mockImplementation(options => actual.resolveKitCli({
...options,
lookupKitEntry: () => pathToFileURL(createRequire(join(runtime, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json'))
.resolve('@deepseek-ai/libreoffice-kit')),
}))
const nodePath = join(root, 'primary-runtime', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
await mkdir(dirname(nodePath), { recursive: true })
await writeFile(nodePath, '')
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('profileContext', {
name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json',
name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: join(runtime, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
})
ctx.provide('dshHomePath', dshHomePath)
@@ -47,17 +90,20 @@ async function boot(profile: string, primaryRuntime: (root: string) => string |
expect(rows.map(row => row.id)).toEqual(['workspace-dependencies', 'skill-office'])
await ctx.loader.root.update(rows.map(row => ({ ...row, name: row.id === 'skill-office' ? 'cordis:office' : 'cordis:dependencies' })))
await ctx.loader.await()
return { ctx, root }
return { ctx, root, runtime, cli }
}
it('mounts Office skills and workspace dependencies from the Desktop carrier paths', async () => {
const { ctx, root } = await boot('desktop', root => join(root, 'primary-runtime'))
it.each(['plain', 'asar'] as const)('resolves Desktop Office CLI paths inside a %s runtime', async (layout) => {
const { ctx, root, runtime, cli } = await boot('desktop', root => join(root, 'primary-runtime'), undefined, undefined, layout)
// Desktop omits pythonEnvironment; the skill-office default selects the bundled environment.
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toEqual({
assetRoot: join(root, 'office-skills'),
node: join(root, 'primary-runtime', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'),
cli: join(root, 'office-cli.js'),
kitRoot: runtime,
cli: undefined,
pythonEnvironment: 'workspace-dependencies',
})
expect((await ctx.skills.get('office-render'))?.content).toContain(JSON.stringify(cli))
expect(ctx.loader.resolve('workspace-dependencies').fiber?.effectiveConfig).toEqual({
source: join(root, 'primary-runtime'), root: join(root, 'home', 'dsh-runtimes', 'dsh-primary-runtime'),
})
@@ -66,27 +112,41 @@ it('mounts Office skills and workspace dependencies from the Desktop carrier pat
expect((await ctx.skills.get('office-docx'))?.content).toMatch(/## Python environment\n\n.*`load_workspace_dependencies`/u)
})
it('keeps Office skills without CLI paths when the Desktop shell supplies no Kit CLI', async () => {
const { ctx, root } = await boot('desktop', root => join(root, 'primary-runtime'), () => undefined)
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toMatchObject({ assetRoot: join(root, 'office-skills'), cli: false, pythonEnvironment: 'workspace-dependencies' })
it.each(['plain', 'development'] as const)('leaves Desktop Kit discovery unrestricted without a runtime root (%s)', async (layout) => {
const { ctx, runtime, cli } = await boot('desktop', root => join(root, 'primary-runtime'), undefined, undefined, layout, () => undefined)
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toHaveProperty('kitRoot', undefined)
if (layout === 'development') {
expect(await realpath(join(runtime, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib', 'cli.js'))).toBe(cli)
expect(cli.startsWith(`${runtime}${sep}`)).toBe(false)
}
expect((await ctx.skills.get('office-render'))?.content).toContain(JSON.stringify(cli))
expect(resolveKitCli).toHaveLastReturnedWith(expect.objectContaining({ kind: 'available', cli }))
})
it('keeps Office skills and warns once when no Kit is available inside the Desktop runtime', async () => {
const { ctx, root } = await boot('desktop', root => join(root, 'primary-runtime'), root => join(root, 'stale-cli.js'), undefined, 'missing')
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toMatchObject({ assetRoot: join(root, 'office-skills'), kitRoot: join(root, 'dsh'), cli: undefined, pythonEnvironment: 'workspace-dependencies' })
expect(ctx.loader.resolve('workspace-dependencies').fiber?.effectiveConfig).toEqual({
source: join(root, 'primary-runtime'), root: join(root, 'home', 'dsh-runtimes', 'dsh-primary-runtime'),
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
const warnings: string[] = []
ctx.logger.exporter({ levels: { default: 2 }, export: ({ type, args }) => { if (type === 'warn') warnings.push(String(args[0])) } })
for (const name of ['office-render', 'office-recalculate', 'office-export-pdf']) {
const content = (await ctx.skills.get(name))?.content
expect(content).toMatch(/\n\nLibreOffice Kit is disabled in this deployment\.$/u)
expect(content).toMatch(/\n\nLibreOffice Kit is not available in this deployment\.$/u)
expect(content).not.toContain('libreofficeKit')
}
expect(warnings).toHaveLength(1)
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
})
it('mounts Office skills with packaged defaults and the user Python outside the Desktop profile', async () => {
it('mounts Office skills with packaged defaults and the user Python outside the Desktop profile without launcher variables', async () => {
const { ctx } = await boot('web', () => undefined, () => undefined)
expect(ctx.loader.resolve('skill-office').disabled).toBe(false)
// Each path expression evaluates to `undefined`; the schema leaves it unset so skill-office applies its packaged default.
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({
assetRoot: undefined, node: undefined, cli: undefined, pythonEnvironment: 'user',
kitRoot: undefined, assetRoot: undefined, node: undefined, cli: undefined, pythonEnvironment: 'user',
})
expect(ctx.loader.resolve('workspace-dependencies').disabled).toBe(true)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
@@ -98,6 +158,39 @@ it('mounts Office skills with packaged defaults and the user Python outside the
expect(ctx.tools.schemas()).toEqual([])
})
it('disables the Kit CLI outside the Desktop profile when the launcher sets an empty CLI', async () => {
const { ctx } = await boot('web', () => undefined, () => '')
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({
kitRoot: undefined, assetRoot: undefined, node: undefined, cli: false, pythonEnvironment: 'user',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
for (const name of ['office-render', 'office-recalculate', 'office-export-pdf']) {
const content = (await ctx.skills.get(name))?.content
expect(content).toMatch(/\n\nLibreOffice Kit is disabled in this deployment\.$/u)
expect(content).not.toContain('libreofficeKit')
}
})
it('uses the launcher Node and Kit CLI outside the Desktop profile', async () => {
const { ctx, root } = await boot('web', () => undefined, root => join(root, 'sidecar-cli.js'), root => ({ node: join(root, 'sidecar-node') }))
for (const file of ['sidecar-cli.js', 'sidecar-node']) await writeFile(join(root, file), '')
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({
kitRoot: undefined, assetRoot: undefined, node: join(root, 'sidecar-node'), cli: join(root, 'sidecar-cli.js'), pythonEnvironment: 'user',
})
expect((await ctx.skills.get('office-render'))?.content)
.toContain(JSON.stringify({ libreofficeKit: { node: join(root, 'sidecar-node'), cli: join(root, 'sidecar-cli.js') } }, undefined, 2))
})
it('reads Office skills from the launcher skill directory outside the Desktop profile', async () => {
const { ctx, root } = await boot('web', () => undefined, () => undefined, root => ({ skills: join(root, 'office-skills') }))
expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({
kitRoot: undefined, assetRoot: join(root, 'office-skills'), node: undefined, cli: undefined, pythonEnvironment: 'user',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
expect((await ctx.skills.get('office-docx'))?.resourceBase)
.toEqual({ kind: 'directory', path: join(root, 'office-skills', 'office-docx').split(sep).join('/') })
})
it('fails only the Office rows when the carrier supplies an unusable primary runtime path', async () => {
const { ctx } = await boot('desktop', () => 'primary-runtime')
for (const id of ['skill-office', 'workspace-dependencies']) {
@@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { OfficeSourceKey, type OfficeToPdfRequest } from '@deepseek-ai/dsh-office-to-pdf'
import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-to-pdf'
import { OfficeSourceKey, type OfficeKitRequest } from '@deepseek-ai/dsh-office-kit'
import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-kit'
import { expect, it, onTestFinished } from 'vitest'
it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => {
@@ -20,9 +20,9 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal
})
const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
.flatMap(patch => patch.insert ?? [])
.filter(row => row.name === '@deepseek-ai/dsh-office-to-pdf')
expect(rows.map(row => row.id)).toEqual(['office-to-pdf'])
const configured = applyEntryPatches(rows, [{ id: 'office-to-pdf', config: { maxConcurrentConversions: 1 } }],
.filter(row => row.name === '@deepseek-ai/dsh-office-kit')
expect(rows.map(row => row.id)).toEqual(['office-kit'])
const configured = applyEntryPatches(rows, [{ id: 'office-kit', config: { maxConcurrentConversions: 1 } }],
(message) => { throw new Error(message) })
const configPath = join(directory, 'cordis.yml')
await writeFile(configPath, JSON.stringify(configured))
@@ -33,19 +33,19 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== '@deepseek-ai/dsh-office-to-pdf') throw new Error(`Unexpected plugin: ${specifier}`)
if (specifier !== '@deepseek-ai/dsh-office-kit') throw new Error(`Unexpected plugin: ${specifier}`)
return LibreOfficeProvider
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await ctx.loader.await()
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')!
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-kit')!
await entry.fiber!.await()
expect(ctx.get('workspaceFiles')).toBeUndefined()
expect(ctx.get('skills')).toBeUndefined()
const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url))
let reads = 0
const request = (key: string): OfficeToPdfRequest => ({
const request = (key: string): OfficeKitRequest => ({
extension: 'docx', priority: 'foreground',
source: { key: OfficeSourceKey(key), version: 'fixture', bytes: bytes.length,
async read(signal, maxBytes) {
@@ -56,7 +56,7 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal
},
},
})
const provider = ctx.officeToPdf
const provider = ctx.officeKit
const [first, second] = await Promise.all([provider.convert(request('first')), provider.convert(request('second'))])
expect(reads).toBe(2)
expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-')
@@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
import OfficeKit from '@deepseek-ai/dsh-office-kit'
import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -35,7 +35,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
})
const configPath = join(directory, 'cordis.yml')
const expectedRows = {
'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf',
'office-kit': '@deepseek-ai/dsh-office-kit',
'ui-sidebar-documentpreview': '@deepseek-ai/dsh-client-ui-sidebar-documentpreview',
}
const rows = loadOverlayPatches('web-office-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
@@ -44,7 +44,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']] }
const clientConfig = DocumentPreview.Config({ office: { maxCachedEntries: 3, maxCachedBytes: 8192 } })
const configured = applyEntryPatches(rows, [
{ id: 'office-to-pdf', config: providerConfig },
{ id: 'office-kit', config: providerConfig },
{ id: 'ui-sidebar-documentpreview', config: clientConfig },
], (message) => { throw new Error(message) })
expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig)
@@ -80,7 +80,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
['@deepseek-ai/dsh-fs-local', LocalFileSystem],
['@deepseek-ai/dsh-typert-registry', TypertRegistry],
['@deepseek-ai/dsh-api-workspace-files', WorkspaceFiles],
['@deepseek-ai/dsh-office-to-pdf', OfficeToPdf],
['@deepseek-ai/dsh-office-kit', OfficeKit],
['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview],
])
ctx.loader.internal = {
@@ -111,25 +111,25 @@ it('loads the shipped Office rows with separately patched settings and authorize
const signal = new AbortController().signal
const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version
const before = session.seq
const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)
const result = await ctx.officeKit.render(scope, 'report.docx', 'foreground', signal)
expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length,
data: Uint8Array.from(pdf), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
data: Uint8Array.from(pdf), missingFonts: ['Missing Serif'], generation: ctx.officeKit.generation })
const readAgain = vi.spyOn(ctx.fs, 'readBytes')
expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
expect(await ctx.officeKit.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
expect(readAgain).not.toHaveBeenCalled()
expect(await readFile(sourcePath, 'utf8')).toBe('authorized OOXML')
expect(session.seq).toBe(before)
expect(ctx.get('agents')).toBeUndefined()
const { maxConcurrentConversions: _count, ...kitOptions } = providerConfig
expect(kit.create).toHaveBeenCalledWith(expect.objectContaining(kitOptions))
await expect(ctx.officeToPdf.render(scope, 'missing.docx', 'foreground', signal))
await expect(ctx.officeKit.render(scope, 'missing.docx', 'foreground', signal))
.rejects.toMatchObject({ code: 'workspace-file/not-found' })
const refusal = new FsError('read refused', 'FS_SANDBOX_DENIED')
vi.spyOn(ctx.fs, 'stat').mockRejectedValueOnce(refusal)
await expect(ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal)
await expect(ctx.officeKit.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal)
if (process.platform !== 'win32') {
await symlink(sourcePath, join(directory, 'link.docx'))
await expect(ctx.officeToPdf.render(scope, 'link.docx', 'foreground', signal))
await expect(ctx.officeKit.render(scope, 'link.docx', 'foreground', signal))
.rejects.toMatchObject({ code: 'workspace-file/not-regular-file' })
}
expect(render).toHaveBeenCalledOnce()
@@ -95,7 +95,7 @@ The pinned [ExcelJS patch](../../../patches/[email protected]) resolves the wo
<a id="office-preview"></a>
## Office preview
Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, zoom 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.
Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, zoom controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-kit/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.
@@ -114,7 +114,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar
<details>
<summary>Office implementation — click to expand</summary>
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`.
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.officeKit` 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-kit/README.md) owns the Host Remote methods, mounted by `api/remotes`.
The Office Remote returns converted PDFs as native `Uint8Array` values through Connection's multipart binary transport. Renderers borrow retained bytes read-only and copy them before Worker transfer.
@@ -142,7 +142,7 @@ No direct effect; what the user reads here never enters a model request.
<a id="known-limitations-and-deferred-work"></a>
- **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. 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-kit/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.
- **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution.
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
@@ -95,7 +95,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本;CSV 还提供
<a id="office-preview"></a>
## Office 预览
将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、缩放控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。
将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、缩放控件、取消和文本选择能力。[Host 提供方](../../document/office-kit/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 后重新加载浏览器页面。
@@ -114,7 +114,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本;CSV 还提供
<details>
<summary>Office 实现——点击展开</summary>
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` 挂载。
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.officeKit` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-kit/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。
Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8Array` PDF。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
@@ -142,7 +142,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
<a id="known-limitations-and-deferred-work"></a>
- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。
- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-kit/README.zh.md)负责。
- **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
- **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
@@ -69,7 +69,7 @@
"@deepseek-ai/dsh-client-ui-sidebar-files": "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-office-kit": "workspace:*",
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
@@ -1,5 +1,5 @@
/** Session-authorized, version-checked Office bytes shared by concurrent preview reads. */
import type { OfficeToPdfPriority, OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
import type { OfficeKitPriority, OfficeKitGeneration } from '@deepseek-ai/dsh-office-kit/types'
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
import type { DocumentFileBytes, SessionFile } from '../rpc.ts'
@@ -7,7 +7,7 @@ import type { DocumentFileBytes, SessionFile } from '../rpc.ts'
/** PDF bytes and conversion metadata owned by Office preview. */
export type OfficeFileBytes = DocumentFileBytes & {
readonly missingFonts: readonly string[]
readonly generation: OfficeToPdfGeneration
readonly generation: OfficeKitGeneration
}
/**
@@ -19,7 +19,7 @@ export type OfficeFileBytes = DocumentFileBytes & {
export type ReadOfficeDocument = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<OfficeFileBytes>>
/** Authorized cached reads carry explicit scheduling intent to the Host. */
export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority) => ReturnType<ReadOfficeDocument>
export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeKitPriority) => ReturnType<ReadOfficeDocument>
type Result = Awaited<ReturnType<ReadOfficeDocument>>
type Success = Extract<Result, { ok: true }>
@@ -36,7 +36,7 @@ export class OfficePreviewCache {
private readonly pending = new Map<string, Pending>()
private bytes = 0
private readers = 0
private generation: OfficeToPdfGeneration | undefined
private generation: OfficeKitGeneration | undefined
private generationQuery = 0
private acceptedQuery = 0
private readonly superseded = new Error()
@@ -57,7 +57,7 @@ export class OfficePreviewCache {
constructor(private readonly stat: Stat, private readonly convert: ReadOfficeBytes,
private readonly maxEntries: number, private readonly maxBytes: number,
private readonly maxPending: number, private readonly maxReaders: number,
private readonly currentGeneration: (signal: AbortSignal) => Promise<RemoteResult<OfficeToPdfGeneration>>,
private readonly currentGeneration: (signal: AbortSignal) => Promise<RemoteResult<OfficeKitGeneration>>,
private readonly busy: () => Error) {}
/**
@@ -68,7 +68,7 @@ export class OfficePreviewCache {
* @param priority - foreground preview or speculative read.
* @returns current PDF bytes borrowed read-only, or a declared source-read failure; cancellation rejects.
*/
async read(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority = 'foreground'): Promise<Result> {
async read(file: SessionFile, signal: AbortSignal, priority: OfficeKitPriority = 'foreground'): Promise<Result> {
signal.throwIfAborted()
this.lifetime.signal.throwIfAborted()
const readerLimit = priority === 'background' ? this.maxReaders - 1 : this.maxReaders
@@ -79,7 +79,7 @@ export class OfficePreviewCache {
try { return await operation } finally { this.readers--; this.reads.delete(operation) }
}
private async lookup(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise<Result> {
private async lookup(file: SessionFile, signal: AbortSignal, priority: OfficeKitPriority): Promise<Result> {
for (let attempt = 0; attempt < 2; attempt++) {
try { return await this.lookupGeneration(file, signal, priority) }
catch (error) { if (error !== this.superseded) throw error }
@@ -87,7 +87,7 @@ export class OfficePreviewCache {
throw this.busy()
}
private async lookupGeneration(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise<Result> {
private async lookupGeneration(file: SessionFile, signal: AbortSignal, priority: OfficeKitPriority): Promise<Result> {
signal = AbortSignal.any([signal, this.lifetime.signal])
signal.throwIfAborted()
const query = ++this.generationQuery
@@ -2,7 +2,7 @@
import type { Context } from '@deepseek-ai/cordis'
import { retainDocumentTabs } from '../document/tab-lifetime.ts'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
import type {} from '@deepseek-ai/dsh-office-kit/remote'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
import { failureLine } from '../failure-line.ts'
@@ -67,10 +67,10 @@ export function apply(ctx: Context, config: Config['office']): void {
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.office.pdf', () => ctx.slots.register({
name: 'sidebar.right.tab.document.office.pdf', key: id, locale: 'sidebarPdf', ...pdfPresentation,
}, LazyPdfBody)))
ctx.inject(['remote', 'remote.officeToPdf', 'remote.workspaceFiles'], (scope) => {
ctx.inject(['remote', 'remote.officeKit', 'remote.workspaceFiles'], (scope) => {
const convert: ReadOfficeBytes = async (file, signal, priority) => {
signal.throwIfAborted()
const result = await scope.remote.officeToPdf.render(file.sessionId, file.path, priority, signal)
const result = await scope.remote.officeKit.render(file.sessionId, file.path, priority, signal)
signal.throwIfAborted()
if (!result.ok && result.error.code === 'document-render/failed') {
throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error })
@@ -93,7 +93,7 @@ export function apply(ctx: Context, config: Config['office']): void {
},
convert, config.maxCachedEntries, config.maxCachedBytes, config.maxPending, config.maxReaders,
async (signal) => {
const result = await scope.remote.officeToPdf.generation(signal)
const result = await scope.remote.officeKit.generation(signal)
signal.throwIfAborted()
if (!result.ok) throw new Error(t('unavailable'), { cause: result.error })
return result
@@ -1,5 +1,5 @@
/** Controlled conversion completions pin shared cancellation, freshness, and bounded binary reuse. */
import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
import type { OfficeKitGeneration } from '@deepseek-ai/dsh-office-kit/types'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -9,12 +9,12 @@ import { OfficePreviewCache } from '../src/client/office/cache.ts'
const file = { sessionId: 's1' as SessionId, path: 'report.docx' }
const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited<ReturnType<ReadOfficeDocument>> => ({
ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) },
ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeKitGeneration) },
})
function harness(entries = 2, bytes = 32, pending = 8, readers = 32) {
const stat = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
const convert = vi.fn<import('../src/client/office/cache.ts').ReadOfficeBytes>().mockResolvedValue(result())
const generation = vi.fn().mockResolvedValue({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
const generation = vi.fn().mockResolvedValue({ ok: true, value: ('renderer' as OfficeKitGeneration) })
const cache = new OfficePreviewCache(stat, convert, entries, bytes, pending, readers, generation, () => new Error('busy'))
return { stat, convert, cache, generation, read: (signal = new AbortController().signal) => cache.read(file, signal) }
}
@@ -115,7 +115,7 @@ it('reuses the borrowed binary PDF at the exact byte budget without copying', as
it('does not retain a different canonical source even when its version token matches', async () => {
const h = harness()
h.convert.mockResolvedValue({ ok: true, value: {
absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeToPdfGeneration, data: new Uint8Array([1]),
absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeKitGeneration, data: new Uint8Array([1]),
} })
try {
await h.read()
@@ -190,7 +190,7 @@ it('clears Client reuse when the Host renderer generation changes', async () =>
const h = harness()
try {
await h.read()
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeKitGeneration) })
h.convert.mockResolvedValue(result('v1', 'pdf!', 'replacement'))
await h.read()
expect(h.convert).toHaveBeenCalledTimes(2)
@@ -229,7 +229,7 @@ it('sends foreground intent to the Host when joining an in-flight prewarm', asyn
it('counts unresolved renderer metadata against the reader limit and joins it on disposal', async () => {
const h = harness(2, 32, 8, 1)
const completed = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
const completed = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeKitGeneration> }>()
h.generation.mockReturnValueOnce(completed.promise)
const first = expect(h.read()).rejects.toMatchObject({ name: 'AbortError' })
await expect(h.read()).rejects.toThrow('busy')
@@ -237,7 +237,7 @@ it('counts unresolved renderer metadata against the reader limit and joins it on
let disposed = false
const closing = h.cache.dispose().then(() => { disposed = true })
expect(disposed).toBe(false)
completed.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
completed.resolve({ ok: true, value: ('renderer' as OfficeKitGeneration) })
await first
await closing
expect(h.stat).not.toHaveBeenCalled()
@@ -258,8 +258,8 @@ it('passes renderer discovery failures through before source authorization and a
it('restarts a stale generation query without invalidating the current renderer', async () => {
const h = harness()
const current = { ok: true as const, value: ('current' as OfficeToPdfGeneration) }
const stale = { ok: true as const, value: ('stale' as OfficeToPdfGeneration) }
const current = { ok: true as const, value: ('current' as OfficeKitGeneration) }
const stale = { ok: true as const, value: ('stale' as OfficeKitGeneration) }
const converted = result('v1', 'pdf!', 'current')
h.convert.mockResolvedValue(converted)
const firstQuery = Promise.withResolvers<typeof current>()
@@ -298,7 +298,7 @@ it('restarts a pending conversion when a newer renderer generation is accepted',
const restarted = expect(previous).resolves.toEqual(converted)
try {
const signal = await entered.promise
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeKitGeneration) })
h.convert.mockResolvedValue(converted)
expect(await h.read()).toEqual(converted)
await restarted
@@ -323,7 +323,7 @@ it('reauthorizes source metadata that completes after the renderer generation ch
const restarted = expect(previous).resolves.toEqual(converted)
try {
await entered.promise
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeKitGeneration) })
h.convert.mockResolvedValue(converted)
expect(await h.read()).toEqual(converted)
completed.resolve(metadata)
@@ -362,23 +362,23 @@ it.each([[2, 32], [8, 2]])('reserves the final pending/reader slot for foregroun
it('reports repeated generation supersession through the localized capacity failure', async () => {
const h = harness()
const first = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
const retry = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
const first = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeKitGeneration> }>()
const retry = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeKitGeneration> }>()
h.generation.mockReturnValueOnce(first.promise)
const work = expect(h.read()).rejects.toThrow('busy')
try {
await h.read()
h.generation.mockReturnValueOnce(retry.promise)
first.resolve({ ok: true, value: ('obsolete' as OfficeToPdfGeneration) })
first.resolve({ ok: true, value: ('obsolete' as OfficeKitGeneration) })
await vi.waitFor(() => { expect(h.generation).toHaveBeenCalledTimes(3) })
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeKitGeneration) })
await h.read()
retry.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
retry.resolve({ ok: true, value: ('renderer' as OfficeKitGeneration) })
await work
expect(h.generation).toHaveBeenCalledTimes(4)
} finally {
first.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
retry.resolve({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
first.resolve({ ok: true, value: ('renderer' as OfficeKitGeneration) })
retry.resolve({ ok: true, value: ('replacement' as OfficeKitGeneration) })
await h.cache.dispose()
}
})
@@ -1,10 +1,10 @@
/** Authorized Host PDFs remain binary through Client reuse and disposal. */
import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
import type { OfficeKitGeneration } from '@deepseek-ai/dsh-office-kit/types'
import { Context } from '@deepseek-ai/cordis'
import { expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
import type {} from '@deepseek-ai/dsh-office-kit/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'
@@ -22,7 +22,7 @@ vi.mock('../src/client/office/face.ts', async (importOriginal) => {
})
const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' }
const generation = ('renderer' as OfficeToPdfGeneration)
const generation = ('renderer' as OfficeKitGeneration)
const pdf = new Uint8Array([37, 80, 68, 70])
const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: pdf }
const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } }
@@ -58,8 +58,8 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
const registry = new DocumentPreviewRegistry()
const removeLocale = vi.fn()
const locale = { register: vi.fn(() => removeLocale), bind: (name: string) => name === 'sidebarDocumentPreview' ? makeTranslate(documentEn) : makeTranslate(en) }
const render = vi.fn<ClientRemote['officeToPdf']['render']>().mockResolvedValue(converted)
const rendererGeneration = vi.fn<ClientRemote['officeToPdf']['generation']>().mockResolvedValue({ ok: true, value: generation })
const render = vi.fn<ClientRemote['officeKit']['render']>().mockResolvedValue(converted)
const rendererGeneration = vi.fn<ClientRemote['officeKit']['generation']>().mockResolvedValue({ ok: true, value: generation })
const stat = vi.fn<ClientRemote['workspaceFiles']['stat']>().mockResolvedValue({ ok: true, value: source })
const readBytes = vi.fn<ClientRemote['workspaceFiles']['readBytes']>().mockResolvedValue({ ok: true, value: { ...source, data: pdf.subarray(0, 1), eof: false } })
const removeNotice = vi.fn()
@@ -69,9 +69,9 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
ctx.provide('slots', { inject: (_name: string, effect: () => () => void) => effect(), register } as never)
ctx.provide('locale', locale as never)
if (missing !== 'remote') {
ctx.provide('remote', { workspaceFiles: { stat, readBytes }, officeToPdf: { render, generation: rendererGeneration } } as never)
ctx.provide('remote', { workspaceFiles: { stat, readBytes }, officeKit: { render, generation: rendererGeneration } } as never)
if (missing !== 'files') ctx.provide('remote.workspaceFiles', { stat, readBytes } as never)
if (missing !== 'render') ctx.provide('remote.officeToPdf', { render, generation: rendererGeneration } as never)
if (missing !== 'render') ctx.provide('remote.officeKit', { render, generation: rendererGeneration } as never)
}
const fiber = ctx.plugin({ apply: (scope: Context) => {
apply(scope, Config({ office: config }).office)
@@ -130,7 +130,7 @@ it.each([
] as const)('shows configuration guidance when %s returns %s', async (method, code) => {
const h = await harness()
try {
const endpoint = method === 'render' ? 'officeToPdf/render' : 'officeToPdf/generation'
const endpoint = method === 'render' ? 'officeKit/render' : 'officeKit/generation'
const failure = new RemoteError(code, 'Office provider is unavailable.', { endpoint })
h[method].mockResolvedValueOnce({ ok: false, error: failure })
await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure })
@@ -141,7 +141,7 @@ it.each([
it('shows configuration guidance when the Host exposes no Office HTTP endpoint', async () => {
const h = await harness()
try {
const failure = new RemoteError('gateway/internal', 'transport failure for /api/officeToPdf/generation: HTTP 404', {})
const failure = new RemoteError('gateway/internal', 'transport failure for /api/officeKit/generation: HTTP 404', {})
h.rendererGeneration.mockResolvedValueOnce({ ok: false, error: failure })
await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure })
expect(h.readBytes).not.toHaveBeenCalled()
@@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
import type { OfficeKitGeneration } from '@deepseek-ai/dsh-office-kit/types'
import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
import type { DocumentContent } from '../src/client/document/contract.ts'
import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx'
@@ -86,7 +86,7 @@ const definition: DocumentPreviewDefinition = {
type Result = Awaited<ReturnType<ReadOfficeDocument>>
const result = (version = 'v1', missingFonts: readonly string[] = []): Result => ({ ok: true, value: {
absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`),
offset: 0, eof: true, missingFonts, generation: 'engine' as OfficeToPdfGeneration,
offset: 0, eof: true, missingFonts, generation: 'engine' as OfficeKitGeneration,
} })
function setup() {
@@ -66,7 +66,7 @@
"path": "../../../vendor/schemastery"
},
{
"path": "../../document/office-to-pdf"
"path": "../../document/office-kit"
},
{
"path": "../ui-settings/tsconfig.client.json"
+4 -4
View File
@@ -15,11 +15,11 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/document-office-conversion/packages:
en: 96f047dcfd9f6480
zh: 7e6cf139c4c72c49
en: a7732e63cb3a127e
zh: 4640139660f35f57
/document-office-conversion/related-documentation:
en: 17eed9809381620e
zh: 3d3f045cec85e4f4
en: b9691c6ee2c4db99
zh: 133970f11bfb85ee
/document-office-conversion/dev-note:
en: 5de1d6304e00c58f
zh: e58fc8dda0bea8d5
+2 -3
View File
@@ -26,8 +26,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference
| Package | Role | ctx key |
|---|---|---|
| [office-engine-resolver](office-engine-resolver/README.md) | Consumer-anchored Kit engine manifests with native ASAR paths | None |
| [office-to-pdf](office-to-pdf/README.md) | Authorized Office bytes to complete PDFs with bounded queues and caching | `ctx.officeToPdf` |
| [office-kit](office-kit/README.md) | Authorized Office bytes to complete PDFs with bounded queues and caching; consumer-anchored Kit entry, CLI, and engine manifests through `/locate` | `ctx.officeKit` |
-----
@@ -36,7 +35,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference
Consumers own source authorization and presentation.
- [Document conversion](../../docs/subsystems/office-to-pdf.md) — shared operation and generated service reference.
- [Document conversion](../../docs/subsystems/office-kit.md) — shared operation and generated service reference.
- [Independent kit ownership](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) — engine distribution and application integration.
- [Workspace Files](../api/workspace-files/README.md) — authorized bounded source reads.
+2 -3
View File
@@ -26,8 +26,7 @@ kind: "package-group"
| 包 | 职责 | ctx 键 |
|---|---|---|
| [office-engine-resolver](office-engine-resolver/README.zh.md) | 以消费方 Kit 为起点解析引擎清单,并映射原生 ASAR 路径 | 无 |
| [office-to-pdf](office-to-pdf/README.zh.md) | 将已授权 Office 字节转换为完整 PDF,并提供有界队列和缓存 | `ctx.officeToPdf` |
| [office-kit](office-kit/README.zh.md) | 将已授权 Office 字节转换为完整 PDF,并提供有界队列和缓存;通过 `/locate` 以消费方 Kit 为起点定位 Kit 入口、CLI 和引擎清单 | `ctx.officeKit` |
-----
@@ -36,7 +35,7 @@ kind: "package-group"
消费者负责源文件授权与展示。
- [文档转换](../../docs/subsystems/office-to-pdf.zh.md) — 共享操作和生成的服务参考。
- [文档转换](../../docs/subsystems/office-kit.zh.md) — 共享操作和生成的服务参考。
- [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。
- [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。
@@ -1,37 +0,0 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/document/office-engine-resolver/README.md
/:
en: 24c13bf062183cfd
zh: 4d52f88ba115c589
/deepseek-ai-dsh-office-engine-resolver:
en: b2018923928c252b
zh: 87626e8a94552a49
/deepseek-ai-dsh-office-engine-resolver/summary:
en: bee6c2600f3d2f49
zh: 83685056df577a44
/deepseek-ai-dsh-office-engine-resolver/table-of-contents:
en: d152484eb41ac6b4
zh: 75f067b4c4218355
/deepseek-ai-dsh-office-engine-resolver/use-this-package:
en: 0bffd7780cc4ab51
zh: aacb0f9a76ff6ba1
/deepseek-ai-dsh-office-engine-resolver/understand-the-implementation:
en: ecc1cc389f0d50d8
zh: ca929a3d20886236
/deepseek-ai-dsh-office-engine-resolver/further-exploration:
en: 6acb078ef7f64b5d
zh: 9889b115126dbf62
/deepseek-ai-dsh-office-engine-resolver/model-experience:
en: 875a75cdbbb9fbd5
zh: f169a1ef9cf0cd95
/deepseek-ai-dsh-office-engine-resolver/model-experience/kv-cache-effect:
en: 801cdc2300902a94
zh: 3ee0cee2bc34847a
/deepseek-ai-dsh-office-engine-resolver/known-limitations-and-deferred-work:
en: 38dd52010491fe88
zh: a992d39a5804e54a
/deepseek-ai-dsh-office-engine-resolver/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
@@ -1,90 +0,0 @@
---
description: "Resolve LibreOffice Kit engine manifests from the consumer's installation, including native ASAR paths."
kind: "package-library"
---
# @deepseek-ai/dsh-office-engine-resolver
English | [中文](README.zh.md)
## Summary
Resolve engine packages for the LibreOffice Kit installation your application actually uses. Native engines inside an ASAR archive resolve to their adjacent unpacked paths; WASM retains its logical path. The [Office-to-PDF provider](../office-to-pdf/README.md) uses this callback without installing module hooks or loading Electron.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Use this library as a dependency of a Node consumer that supplies Kit's `resolveEnginePackage` option. The consumer owns its Kit dependency; this package does not depend on Kit and has no `cordis.yml` configuration.
```ts
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
const options = {
resolveEnginePackage: createEnginePackageResolver(import.meta.resolve('@deepseek-ai/libreoffice-kit')),
}
```
Pass `options` to Kit's `createConverter` or `discoverRuntime`, or as `options` in the editor's `openDocument` request. Resolve the Kit entry in the consuming module: anchoring at this library could select a different installation. The factory also accepts an absolute entry path or a file `URL`; each callback retains its own resolver.
A successful callback returns an absolute engine `package.json` path. An absent package preserves Node's original `MODULE_NOT_FOUND`, allowing Kit's native-to-WASM fallback. If a package directory exists, including a dangling symlink, but its manifest cannot resolve, the callback throws an incomplete-installation error with the Node error as `cause`; reinstall that package. Other resolution and filesystem inspection errors propagate unchanged. Kit owns validation of the returned manifest and required assets.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The [factory](src/index.ts) creates a Node resolver from the supplied Kit entry and resolves engine manifests relative to it. Native paths replace only the first `.asar` archive segment with `.asar.unpacked`, regardless of whether the process runs in Electron. Already-unpacked paths and paths outside an archive remain unchanged. `@deepseek-ai/libreoffice-kit-wasm` always keeps Node's logical path for archive-aware filesystem access.
The callback only resolves paths and inspects failed installations. It does not load engine code, register services, mutate module resolution, or retain module-level state. [Desktop packaging](../../../apps/desktop/packaging/README.md#runtime-file-selection) owns the unpacked files.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Office-to-PDF provider](../office-to-pdf/README.md) — converter configuration and request ownership.
- [Document conversion](../../../docs/subsystems/office-to-pdf.md) — shared Host conversion operation.
-----
<a id="model-experience"></a>
## Model Experience
None, as this package resolves engine manifests without constructing model input or registering tools.
#### KV Cache effect
None; resolution does not construct or modify model requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- The caller must supply the entry of the Kit installation it uses; the resolver does not discover or install Kit or engines.
- Mapping a native path does not verify that the unpacked copy exists. Kit rejects missing manifests or assets instead of treating them as an absent native package.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -1,90 +0,0 @@
---
description: "从消费方的安装位置解析 LibreOffice Kit 引擎清单,包括原生引擎的 ASAR 路径。"
kind: "package-library"
---
# @deepseek-ai/dsh-office-engine-resolver
[English](README.md) | 中文
## 概述
为应用实际使用的 LibreOffice Kit 安装解析引擎包。ASAR 归档内的原生引擎解析为相邻的解包路径;WASM 保留逻辑路径。[Office-to-PDF 提供方](../office-to-pdf/README.zh.md)使用此回调,无需安装模块钩子或加载 Electron。
## 目录
- [使用此包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延后工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用此包
将此库作为提供 Kit `resolveEnginePackage` 选项的 Node 消费方的依赖。消费方负责声明自己的 Kit 依赖;此包不依赖 Kit,也没有 `cordis.yml` 配置。
```ts
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
const options = {
resolveEnginePackage: createEnginePackageResolver(import.meta.resolve('@deepseek-ai/libreoffice-kit')),
}
```
将 `options` 传给 Kit 的 `createConverter` 或 `discoverRuntime`,或作为编辑器 `openDocument` 请求的 `options` 字段。在消费模块中解析 Kit 入口:以此库为起点可能选中不同的安装。工厂也接受绝对入口路径或文件 `URL`;每个回调持有独立的解析器。
回调成功时返回引擎 `package.json` 的绝对路径。包不存在时保留 Node 原始的 `MODULE_NOT_FOUND`,允许 Kit 从原生引擎回退到 WASM。如果包目录存在(包括断开的符号链接),但清单无法解析,回调抛出安装不完整错误,并将 Node 错误保存在 `cause` 中;此时应重新安装该包。其他解析错误和文件系统检查错误原样传播。Kit 负责验证返回的清单和必需资源。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部细节 — 点击展开</summary>
[工厂](src/index.ts)从传入的 Kit 入口创建 Node 解析器,并相对该入口解析引擎清单。原生路径只将第一个 `.asar` 归档段替换为 `.asar.unpacked`,不论进程是否运行在 Electron 中。已解包路径及归档外路径保持不变。`@deepseek-ai/libreoffice-kit-wasm` 始终保留 Node 逻辑路径,供支持归档的文件系统访问。
回调只解析路径并检查失败的安装。它不加载引擎代码、不注册服务、不修改模块解析,也不保留模块级状态。[Desktop 打包](../../../apps/desktop/packaging/README.zh.md#runtime-file-selection)负责解包文件。
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [Office-to-PDF 提供方](../office-to-pdf/README.zh.md) — 转换器配置和请求所有权。
- [文档转换](../../../docs/subsystems/office-to-pdf.zh.md) — 共享宿主转换操作。
-----
<a id="model-experience"></a>
## 模型体验
无,因为此包只解析引擎清单,不构造模型输入或注册工具。
#### KV Cache 影响
无;解析不会构造或修改模型请求。
## 已知限制与延后工作
<a id="known-limitations-and-deferred-work"></a>
- 调用方必须提供其实际使用的 Kit 安装入口;解析器不发现或安装 Kit 或引擎。
- 映射原生路径不验证解包副本是否存在。Kit 会拒绝缺失的清单或资源,而不是将其视为原生包未安装。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文 — 点击展开</summary>
无。
</details>
@@ -1,35 +0,0 @@
{
"name": "@deepseek-ai/dsh-office-engine-resolver",
"description": "Consumer-anchored LibreOffice Kit engine resolution with native ASAR paths",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/document/office-engine-resolver"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~"
}
}
@@ -1,47 +0,0 @@
/** Locate LibreOffice Kit engine packages from the consumer's Kit installation without module hooks. */
import { lstatSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
/**
* Locate an engine package manifest for Kit.
* @param name - Engine package name requested by Kit.
* @returns Absolute path of the package's `package.json`.
* @throws An error with code `MODULE_NOT_FOUND` when the package is absent.
*/
export type EnginePackageResolver = (name: string) => string
/**
* Anchor engine resolution to the Kit entry actually used by the consumer, not this package's installation.
* Each returned callback retains its own Node resolver and installs no module hooks.
* @param kitEntry - Absolute Kit entry path or file URL, normally the consumer's `import.meta.resolve('@deepseek-ai/libreoffice-kit')`.
* @returns A callback for Kit's `resolveEnginePackage` option. Native paths map the first ASAR segment to its unpacked copy;
* WASM and paths outside archives stay unchanged. Kit validates the returned manifest and required engine assets.
* @throws Node's error if `kitEntry` cannot be used by `createRequire`.
* On invocation, the callback preserves Node errors for absent packages and other resolution failures.
* An installed package with an unresolvable manifest throws an incomplete-installation Error with the Node error as its cause,
* preventing engine fallback. Filesystem inspection errors propagate unchanged.
*/
export function createEnginePackageResolver(kitEntry: string | URL): EnginePackageResolver {
const fromKit = createRequire(kitEntry)
return (name) => {
let manifest: string
try {
manifest = fromKit.resolve(`${name}/package.json`)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') {
const installed = (fromKit.resolve.paths(name) ?? []).some(directory =>
lstatSync(join(directory, name), { throwIfNoEntry: false }) !== undefined,
)
if (installed) throw new Error(`Installed LibreOfficeKit package is incomplete: ${name}. Reinstall the package.`, { cause: error })
}
throw error
}
return name === '@deepseek-ai/libreoffice-kit-wasm' ? manifest : unpackedPath(manifest)
}
}
/** Map only the first ASAR archive segment; already-unpacked paths pass through unchanged. */
function unpackedPath(path: string): string {
return path.replace(/\.asar(?=[\\/])/, '.asar.unpacked')
}
@@ -1,11 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}
@@ -0,0 +1,40 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/document/office-kit/README.md
/:
en: d82e65e0aba9dfff
zh: d153f62694b8c0c6
/deepseek-ai-dsh-office-kit:
en: 37156fb2f1de123c
zh: 44b77def8f181d4b
/deepseek-ai-dsh-office-kit/summary:
en: 8594151e982956c1
zh: bf62ad569be39d7f
/deepseek-ai-dsh-office-kit/table-of-contents:
en: 8454159e70cbfb27
zh: ce266c78e285fe9c
/deepseek-ai-dsh-office-kit/use-this-package:
en: bdb62ed6ce658cf9
zh: bf7d07fef918380f
/deepseek-ai-dsh-office-kit/locate-kit:
en: 37c66ceeb4ab5c84
zh: 7371945d1e0285c4
/deepseek-ai-dsh-office-kit/understand-the-implementation:
en: 171f940605ab3c8b
zh: 784e20c927427316
/deepseek-ai-dsh-office-kit/further-exploration:
en: 947e55254885b2b7
zh: 5b4138196e4fd688
/deepseek-ai-dsh-office-kit/model-experience:
en: b03ae5780f8495eb
zh: 2a5428663141e3db
/deepseek-ai-dsh-office-kit/model-experience/kv-cache-effect:
en: e77332dfb6691852
zh: 2d7ce43f908b35ef
/deepseek-ai-dsh-office-kit/known-limitations-and-deferred-work:
en: ead6d24281dee00e
zh: 220fdf2d96bcbc19
/deepseek-ai-dsh-office-kit/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
+138
View File
@@ -0,0 +1,138 @@
---
description: "LibreOffice Kit location and Host Office conversion with the independently published kit."
kind: "package-reference"
---
# @deepseek-ai/dsh-office-kit
English | [中文](README.zh.md)
## Summary
Convert Office documents to PDFs on the Host computer. Targets with a declared native LibreOffice engine use it; other targets use Node WASM. The provider accepts DOC, DOCX, XLS, XLSX, PPT, and PPTX. OOXML conversion returns missing-font names; binary Office conversion returns an empty list. The `/locate` entry finds the Kit installation a consumer uses, its CLI, and its engine packages without loading Cordis or Kit.
## Table of Contents
- [Use this package](#use-this-package)
- [Locate Kit](#locate-kit)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `office-kit`. Independent compositions mount `@deepseek-ai/dsh-office-kit` as a `cordis.yml` row.
Callers submit authorized source identity, version, optional byte size, a deferred bounded read, Office extension, and scheduling priority through `ctx.officeKit.convert()`. A changed source version rejects conversion. Results contain caller-owned PDF bytes, missing fonts, a cache key, and a conversion generation that changes on configuration replacement. Cancellation rejects with its reason; conversion failures use `OfficeKitError`.
The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API at kit version `0.2.3`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities.
The provider registers even when its Kit is absent, such as a Python SDK executable launched without a downloaded Office sidecar. A conversion that needs a new converter then rejects with `OfficeKitError` code `unavailable`, and the provider logs each distinct failure once through `ctx.logger`. A Kit entry that fails to import behaves the same way. The provider retains only a successful import, so a later conversion locates and imports Kit again. The Office preview shows its localized `unavailable` message for the `document-render/failed` reason.
Browsers request PDFs through the `officeKit.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry native PDF bytes through the binary Remote multipart transport, plus missing fonts and conversion generation. The `officeKit.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor.
| Field | Default | Meaning |
|---|---|---|
| `maxConcurrentConversions` | `2` | Maximum active converters; queued calls remain cancellable. |
| `timeoutMs` | `60000` | Conversion deadline after a converter is acquired. |
| `maxInputBytes` | `52428800` | Maximum source bytes. |
| `maxOutputBytes` | `104857600` | Maximum complete PDF bytes. |
| `maxImageResolution` | `192` | Maximum raster-image DPI; overrides the kit default of `144`. |
| `fontFallbacks` | Kit defaults | Ordered font-family preference groups; each group requires at least two names containing non-whitespace characters. |
The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-office-kit) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences.
The [bounded conversion decision](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md) explains queue admission, cache limits, and shared cancellation.
The provider retains successful PDFs by converter generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready.
Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles.
The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, multipart framing, caller-retained results, or PDF.js page rendering.
<a id="locate-kit"></a>
## Locate Kit
Node consumers that load Kit or run its CLI import `@deepseek-ai/dsh-office-kit/locate`. This entry imports only Node built-in modules as values, reads no environment variables, and has no `cordis.yml` configuration. The consumer owns its Kit dependency. The module and CLI resolvers take a lookup written in the consuming module, because `import.meta.resolve` there selects the consumer's installation and throws when Kit is absent:
```ts
import { resolveKitCli, resolveKitModule } from '@deepseek-ai/dsh-office-kit/locate'
const lookupKitEntry = () => import.meta.resolve('@deepseek-ai/libreoffice-kit')
const kit = resolveKitModule(lookupKitEntry)
const cli = resolveKitCli({ lookupKitEntry })
```
`resolveKitModule` returns `{ kind: 'available', entry, resolveEnginePackage }` or `{ kind: 'missing', error }`. Pass `entry` to a dynamic `import()` and `resolveEnginePackage` to Kit's `createConverter` or `discoverRuntime`, or as `options` in the editor's `openDocument` request. The lookup's thrown value becomes `error`.
`resolveKitCli` accepts optional `node` and `cli` paths and returns `{ kind: 'disabled' }` for `cli: false`, `{ kind: 'missing', error }` when `cli` is omitted and the lookup throws, or `{ kind: 'available', node, cli }`. An omitted `node` uses `process.execPath`; in Electron it throws, because that path is the application binary. An omitted `cli` uses the `./cli` export of the found Kit and maps ASAR paths to their unpacked copies. Explicit CLI paths stay unchanged. A configured or found path that is not an absolute existing file throws. Each call reads the filesystem again.
`root` optionally restricts automatic CLI discovery to an absolute directory; omission leaves it unrestricted, and explicit `cli` bypasses it. The Kit entry and CLI must both lie inside the root after realpath. The root and both paths map their first `.asar` segment, including a root ending in `.asar`, to `.asar.unpacked` before comparison. An unavailable root or an escaping path returns `missing` with its error; a relative root throws.
`unpackedPath(path)` exposes the same mapping for standalone callers that must anchor a lookup outside ASAR, such as packaging smoke checks. It does not access the filesystem.
`createEnginePackageResolver(kitEntry)` creates the engine callback directly from an absolute entry path or file URL; each callback retains its own resolver. A successful callback returns an absolute engine `package.json` path. An absent package preserves Node's original `MODULE_NOT_FOUND`, allowing Kit's native-to-WASM fallback. If a package directory exists, including a dangling symlink, but its manifest cannot resolve, the callback throws an incomplete-installation error with the Node error as `cause`; reinstall that package. Other resolution and filesystem inspection errors propagate unchanged. Kit owns validation of the returned manifest and required assets.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
Until a Kit import succeeds, each converter request passes a lookup that returns the provider's own `import.meta.resolve('@deepseek-ai/libreoffice-kit')` to [`resolveKitModule`](src/locate.ts), which returns the Kit entry with an engine-package resolver anchored there, or `missing`. The provider then dynamically imports that entry; it has no static Kit value import. This anchors engine lookup to the Kit installation used by the provider.
The `/locate` functions call the consumer's lookup once per resolution and treat any thrown value as an absent Kit. The CLI resolves through `createRequire(entry)` as the Kit package's own `./cli` export. Automatic CLI and native engine paths share one mapping that replaces only the first `.asar` archive segment with `.asar.unpacked`, regardless of whether the process runs in Electron. Already-unpacked paths and paths outside an archive remain unchanged. `@deepseek-ai/libreoffice-kit-wasm` always keeps Node's logical path for archive-aware filesystem access. These functions do not import Kit, load engine code, register services, mutate module resolution, or retain module-level state; [Desktop packaging](../../../apps/desktop/packaging/README.md#runtime-file-selection) owns which packages are unpacked.
Invalid engine metadata, missing required assets, and conversion errors reject the request instead of switching engines. The shared WASM engine uses LibreOffice's CPU image filter. Native conversion uses its separate platform engine.
Each concurrent slot lazily creates and reuses one kit converter. The provider writes authorized input into a private temporary directory, reads a bounded regular PDF, and removes the directory before settling. Canceling an admitted reader does not block later queued work. Reader cancellation releases only that reader; the final reader and provider disposal cancel shared work. Disposal reports `unavailable` to outstanding readers and joins conversions and converter teardown.
Remote file reads recheck content authorization and source version before consulting the conversion cache. Deferred reads run after conversion admission and verify the source version after reading. Source-read failures pass through; conversion failures return `document-render/failed` with a classified reason and no engine diagnostics. Unload cancels and joins authorization reads, Remote requests, and conversion work.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Office Kit](../../../docs/subsystems/office-kit.md) — composition and input/result ownership.
- [Office skills](../../skill/skill-office/README.md) — Kit CLI paths in operation skills.
- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads.
-----
<a id="model-experience"></a>
## Model Experience
None, as this package converts bytes without model-facing tools, messages, or Session events.
#### KV Cache effect
None; conversion does not construct or modify model requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- Conversion fidelity and installed native/WASM assets belong to `@deepseek-ai/libreoffice-kit`; this provider neither searches for system LibreOffice nor downloads an engine at runtime.
- Queue waiting time is not bounded by `timeoutMs`, which starts only when kit conversion begins.
- `/locate` consumers must supply a lookup for the Kit installation they use; the functions do not search other locations or install Kit or engines.
- Mapping a native path does not verify that the unpacked copy exists. Kit rejects missing manifests or assets instead of treating them as an absent native package.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
+138
View File
@@ -0,0 +1,138 @@
---
description: "定位 LibreOffice Kit,并通过独立发布的 kit 在 Host 转换 Office 文档。"
kind: "package-reference"
---
# @deepseek-ai/dsh-office-kit
[English](README.md) | 中文
## 概述
在宿主计算机上将 Office 文档转换为 PDF。声明了原生 LibreOffice 引擎的目标使用原生引擎,其余目标使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。`/locate` 入口定位消费方使用的 Kit 安装、其 CLI 和引擎包,不加载 Cordis 或 Kit。
## 目录
- [使用此包](#use-this-package)
- [定位 Kit](#locate-kit)
- [理解实现](#understand-the-implementation)
- [延伸阅读](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延后工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用此包
[Web bundle](../../bundle/web-app/README.zh.md)以 `office-kit` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-office-kit`。
调用方通过 `ctx.officeKit.convert()` 提交已授权源的标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。源版本变化会拒绝转换。结果包含调用方拥有的 PDF 字节、缺失字体、缓存键和转换 generation;配置替换后 generation 随之改变。取消以原因为拒绝值,转换失败使用 `OfficeKitError`。
此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API,kit 版本为 `0.2.3`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。
Kit 缺失时提供方照常注册,例如未下载 Office sidecar 就启动的 Python SDK 可执行程序。此时需要新转换器的转换以代码为 `unavailable` 的 `OfficeKitError` 拒绝,提供方通过 `ctx.logger` 对每种不同的失败只报告一次。Kit 入口导入失败时行为相同。提供方只保留成功的导入,因此之后的转换会重新定位并导入 Kit。Office 预览对 `document-render/failed` 原因显示本地化的 `unavailable` 提示。
浏览器通过 `officeKit.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,通过二进制 Remote 的 multipart 传输携带原生 PDF 字节,并携带缺失字体和转换 generation。`officeKit.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。
| 字段 | 默认值 | 含义 |
|---|---|---|
| `maxConcurrentConversions` | `2` | 最大活跃转换器数量;排队调用仍可取消。 |
| `timeoutMs` | `60000` | 获取转换器后的转换期限。 |
| `maxInputBytes` | `52428800` | 最大源字节数。 |
| `maxOutputBytes` | `104857600` | 最大完整 PDF 字节数。 |
| `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 |
| `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 |
[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-office-kit)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。
[有界转换决策](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md)说明队列准入、缓存限额与共享取消的设计依据。
提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。
准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。
默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、multipart 封装、调用方保留的结果或 PDF.js 页面渲染。
<a id="locate-kit"></a>
## 定位 Kit
加载 Kit 或运行 Kit CLI 的 Node 消费方导入 `@deepseek-ai/dsh-office-kit/locate`。此入口只以值的形式导入 Node 内置模块,不读取环境变量,也没有 `cordis.yml` 配置。消费方负责声明自己的 Kit 依赖。模块与 CLI 解析器接收在消费模块中编写的查找函数:在那里调用 `import.meta.resolve` 会选中消费方的安装,且 Kit 缺失时会抛出:
```ts
import { resolveKitCli, resolveKitModule } from '@deepseek-ai/dsh-office-kit/locate'
const lookupKitEntry = () => import.meta.resolve('@deepseek-ai/libreoffice-kit')
const kit = resolveKitModule(lookupKitEntry)
const cli = resolveKitCli({ lookupKitEntry })
```
`resolveKitModule` 返回 `{ kind: 'available', entry, resolveEnginePackage }` 或 `{ kind: 'missing', error }`。将 `entry` 传给动态 `import()`,将 `resolveEnginePackage` 传给 Kit 的 `createConverter` 或 `discoverRuntime`,或作为编辑器 `openDocument` 请求的 `options` 字段。查找函数抛出的值成为 `error`。
`resolveKitCli` 接受可选的 `node` 和 `cli` 路径:`cli: false` 时返回 `{ kind: 'disabled' }`;未配置 `cli` 且查找函数抛出时返回 `{ kind: 'missing', error }`;否则返回 `{ kind: 'available', node, cli }`。未配置 `node` 时使用 `process.execPath`;在 Electron 中会抛出,因为该路径是应用程序本身。未配置 `cli` 时使用找到的 Kit 的 `./cli` 导出,并把 ASAR 路径映射到解包副本。显式 CLI 路径保持不变。配置或找到的路径不是存在的绝对路径文件时抛出。每次调用都会重新读取文件系统。
`root` 可将自动 CLI 查找限制在一个绝对目录内;省略时不限制,显式 `cli` 不受此限制。Kit 入口和 CLI 在 realpath 后必须都位于该根目录内。比较前,根目录和两个路径中的第一个 `.asar` 段都会映射为 `.asar.unpacked`,包括以 `.asar` 结尾的根目录。根目录不可用或路径越界时返回携带错误的 `missing`;相对根目录会抛出。
`unpackedPath(path)` 为需要在 ASAR 外锚定查找的独立调用方(例如打包冒烟检查)提供相同映射。它不访问文件系统。
`createEnginePackageResolver(kitEntry)` 直接从绝对入口路径或文件 URL 创建引擎回调;每个回调持有独立的解析器。回调成功时返回引擎 `package.json` 的绝对路径。包不存在时保留 Node 原始的 `MODULE_NOT_FOUND`,允许 Kit 从原生引擎回退到 WASM。如果包目录存在(包括断开的符号链接),但清单无法解析,回调抛出安装不完整错误,并将 Node 错误保存在 `cause` 中;此时应重新安装该包。其他解析错误和文件系统检查错误原样传播。Kit 负责验证返回的清单和必需资源。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部细节 — 点击展开</summary>
在 Kit 导入成功之前,每次需要转换器时,提供方都把返回自身 `import.meta.resolve('@deepseek-ai/libreoffice-kit')` 的查找函数传给 [`resolveKitModule`](src/locate.ts),它返回 Kit 入口和以其为起点的引擎包解析器,或返回 `missing`。随后提供方动态导入该入口;提供方没有静态导入 Kit 的值。这使引擎查找以提供方实际使用的 Kit 安装为起点。
`/locate` 的函数每次解析调用一次消费方的查找函数,并把任何抛出的值视为 Kit 缺失。CLI 通过 `createRequire(entry)` 解析为 Kit 包自身的 `./cli` 导出。自动 CLI 路径和原生引擎路径共用同一映射,只将第一个 `.asar` 归档段替换为 `.asar.unpacked`,不论进程是否运行在 Electron 中。已解包路径及归档外路径保持不变。`@deepseek-ai/libreoffice-kit-wasm` 始终保留 Node 逻辑路径,供支持归档的文件系统访问。这些函数不导入 Kit、不加载引擎代码、不注册服务、不修改模块解析,也不保留模块级状态;[Desktop 打包](../../../apps/desktop/packaging/README.zh.md#runtime-file-selection)负责决定解包哪些包。
引擎元数据无效、必需资源缺失和转换错误会拒绝请求,不会切换引擎。共享的 WASM 引擎使用 LibreOffice 的 CPU 图像过滤器。原生转换使用独立的平台引擎。
每个并发槽按需创建并复用一个 kit 转换器。提供方将已授权输入写入私有临时目录,读取有大小上限的普通 PDF 文件,并在完成前删除目录。取消已准入的读取方不会阻塞后续排队工作。读取方取消只会释放该读取方;最后一个读取方和提供方卸载会取消共享工作。卸载向未完成读取方报告 `unavailable`,并等待转换及转换器清理结束。
Remote 文件读取在查询转换缓存前重新检查内容读取授权和源版本。延迟读取在取得转换容量后执行,并在读取后验证源版本。源读取失败直接传递;转换失败返回 `document-render/failed` 及分类原因,不暴露引擎诊断。卸载会取消并等待授权读取、Remote 请求和转换工作全部结束。
</details>
-----
<a id="further-exploration"></a>
## 延伸阅读
- [Office Kit](../../../docs/subsystems/office-kit.zh.md) — 组合与输入、结果所有权。
- [Office 技能](../../skill/skill-office/README.zh.md) — 操作技能中的 Kit CLI 路径。
- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。
-----
<a id="model-experience"></a>
## 模型体验
无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。
#### KV Cache effect
无;转换不会构造或修改模型请求。
## 已知限制与延后工作
<a id="known-limitations-and-deferred-work"></a>
- 转换保真度和已安装的原生/WASM 资产由 `@deepseek-ai/libreoffice-kit` 负责;此提供方不查找系统 LibreOffice,也不在运行时下载引擎。
- `timeoutMs` 仅在 kit 开始转换时计时,不限制队列等待时间。
- `/locate` 的消费方必须为自己使用的 Kit 安装提供查找函数;这些函数不搜索其他位置,也不安装 Kit 或引擎。
- 映射原生路径时不验证解包副本是否存在。Kit 会拒绝缺失的清单或资源,而不会将其视为原生包缺失。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文 — 点击展开</summary>
无。
</details>
@@ -0,0 +1,5 @@
{
"meta": {
"title": "office-kit"
}
}
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-office-to-pdf",
"description": "Shared Office-to-PDF conversion with bounded queues and caching",
"name": "@deepseek-ai/dsh-office-kit",
"description": "LibreOffice Kit location and shared Office-to-PDF conversion with bounded queues and caching",
"version": "0.2.2-rc.1",
"publishConfig": {
"access": "public"
@@ -12,13 +12,17 @@
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/document/office-to-pdf"
"directory": "packages/document/office-kit"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./locate": {
"types": "./lib/types/locate.d.ts",
"default": "./lib/types/locate.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./types": {
@@ -60,7 +64,6 @@
"@deepseek-ai/libreoffice-kit": "0.2.3",
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-office-engine-resolver": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"zod": "4.4.3"
}
@@ -1,15 +1,15 @@
/** Classified failures shared by conversion and its consumers. */
import type { OfficeToPdfErrorCode } from './types.ts'
import type { OfficeKitErrorCode } from './types.ts'
/** Classified conversion failure; engine details stay in the cause. */
export class OfficeToPdfError extends Error {
export class OfficeKitError extends Error {
/**
* @param code - category suitable for a conversion consumer.
* @param message - diagnostic explaining the failed conversion.
* @param options - underlying engine or filesystem failure.
*/
constructor(readonly code: OfficeToPdfErrorCode, message: string, options?: ErrorOptions) {
constructor(readonly code: OfficeKitErrorCode, message: string, options?: ErrorOptions) {
super(message, options)
this.name = 'OfficeToPdfError'
this.name = 'OfficeKitError'
}
}
@@ -11,21 +11,21 @@ export type OfficeSourceKey = Branded<'OfficeSourceKey'>
export function OfficeSourceKey(key: string): OfficeSourceKey { return brandString<OfficeSourceKey>(key) }
/** One provider lifetime, including its engine, rendering settings, and font configuration. */
export type OfficeToPdfGeneration = Branded<'OfficeToPdfGeneration'>
export type OfficeKitGeneration = Branded<'OfficeKitGeneration'>
/**
* Label a provider lifetime.
* @param value - unique generation created by the provider.
* @returns branded converter generation.
*/
export function OfficeToPdfGeneration(value: string): OfficeToPdfGeneration {
return brandString<OfficeToPdfGeneration>(value)
export function OfficeKitGeneration(value: string): OfficeKitGeneration {
return brandString<OfficeKitGeneration>(value)
}
/** Provider generation and source-content digest; consumers must not parse it. */
export type OfficeToPdfKey = Branded<'OfficeToPdfKey'>
export type OfficeKitKey = Branded<'OfficeKitKey'>
/**
* Label a converter-owned content identity.
* @param value - generation and content identity created by the provider.
* @returns branded conversion identity.
*/
export function OfficeToPdfKey(value: string): OfficeToPdfKey { return brandString<OfficeToPdfKey>(value) }
export function OfficeKitKey(value: string): OfficeKitKey { return brandString<OfficeKitKey>(value) }
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more