From 96e68ded13526c2b2a0cf1eb13eef787b9714537 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 13:41:31 +0800 Subject: [PATCH 01/18] feat(document): decouple shared Office conversion from preview --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 14 +- ...-executable-sdk-runtime-distribution.zh.md | 14 +- ...9-14-independent-libreoffice-kit.i18n.yaml | 6 + .../2026-09-14-independent-libreoffice-kit.md | 29 + ...26-09-14-independent-libreoffice-kit.zh.md | 29 + ...6-09-15-bounded-office-rendering.i18n.yaml | 6 + .../2026-09-15-bounded-office-rendering.md | 33 + .../2026-09-15-bounded-office-rendering.zh.md | 33 + ...26-09-15-platform-office-engines.i18n.yaml | 6 + .../2026-09-15-platform-office-engines.md | 29 + .../2026-09-15-platform-office-engines.zh.md | 29 + THIRD_PARTY_NOTICES.md | 8 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/scripts/prepare-dsh.ts | 4 + apps/desktop/scripts/prepare-package-set.ts | 11 +- apps/desktop/scripts/runtime-file-policy.ts | 4 + .../desktop/tests/prepare-package-set.spec.ts | 23 +- .../desktop/tests/runtime-file-policy.spec.ts | 5 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 + docs/capability-seams.zh.md | 6 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 51 ++ docs/config-catalog.zh.md | 51 ++ docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/document-render.i18n.yaml | 6 + docs/subsystems/document-render.md | 62 ++ docs/subsystems/document-render.zh.md | 62 ++ docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 10 + docs/subsystems/workspace.zh.md | 10 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 2 + packages/api/workspace-files/README.zh.md | 2 + packages/api/workspace-files/src/index.ts | 16 +- .../workspace-files/tests/read-all.spec.ts | 11 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 3 + packages/bundle/web-app/package.json | 4 +- .../web-app/tests/document-conversion.e2e.ts | 76 +++ .../tests/fixtures/document-conversion.docx | Bin 0 -> 869 bytes packages/document/README.i18n.yaml | 6 + packages/document/README.md | 51 ++ packages/document/README.zh.md | 51 ++ .../README.i18n.yaml | 6 + .../document-render-libreoffice/README.md | 97 +++ .../document-render-libreoffice/README.zh.md | 97 +++ .../document-render-libreoffice/package.json | 43 ++ .../document-render-libreoffice/src/index.ts | 169 ++++++ .../document-render-libreoffice/src/output.ts | 39 ++ .../document-render-libreoffice/src/queue.ts | 232 ++++++++ .../tests/output.spec.ts | 64 ++ .../tests/provider.spec.ts | 269 +++++++++ .../tests/queue.spec.ts | 563 ++++++++++++++++++ .../document-render-libreoffice/tsconfig.json | 21 + .../document/document-render/README.i18n.yaml | 6 + packages/document/document-render/README.md | 75 +++ .../document/document-render/README.zh.md | 75 +++ .../document/document-render/package.json | 43 ++ .../document/document-render/src/index.ts | 44 ++ .../document/document-render/src/types.ts | 71 +++ .../document/document-render/tsconfig.json | 18 + .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 1 + .../webworker-runtime/README.zh.md | 1 + .../webworker-runtime/src/module-proxies.ts | 1 + .../webworker-runtime/src/node/builtins.ts | 2 + .../node/external_packages/libreoffice-kit.ts | 12 + .../external_packages/replaced-externals.ts | 1 + .../tests/node/node-stubs.spec.ts | 9 + .../extensions/tool-cordis/src/api-catalog.ts | 53 ++ pnpm-lock.yaml | 174 ++++++ pnpm-workspace.yaml | 7 + python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 6 +- python/sdk-runtime/README.zh.md | 6 +- python/sdk-runtime/hatch_build.py | 11 + python/sdk-runtime/pyproject.toml | 2 +- python/sdk-runtime/runtime-bootstrap.mjs | 15 +- .../src/deepseek_harness_runtime/__init__.py | 10 +- python/sdk/tests/test_release_version.py | 46 ++ python/sdk/tests/test_runtime_resolution.py | 54 ++ .../build-exe-for-python-sdk-office.spec.ts | 137 +++++ scripts/build-exe-for-python-sdk-office.ts | 94 +++ scripts/build-exe-for-python-sdk.ts | 26 +- scripts/build-python-release.py | 58 +- scripts/fixtures/python-sdk-office.mjs | 15 + scripts/gen-cordis-catalog.ts | 5 + scripts/gen-doc-graphs.ts | 6 + scripts/gen-third-party-notices.spec.ts | 23 + scripts/gen-third-party-notices.ts | 21 +- scripts/smoke-python-runtime.py | 77 ++- .../verify-default-product-isolation.spec.ts | 21 + scripts/verify-default-product-isolation.ts | 3 + .../verify-package-readme-model-experience.ts | 2 + scripts/verify-repository-references.spec.ts | 9 + scripts/verify-repository-references.ts | 4 +- tsconfig.base.json | 2 + tsconfig.host.json | 2 + 109 files changed, 3612 insertions(+), 65 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md create mode 100644 docs/subsystems/document-render.i18n.yaml create mode 100644 docs/subsystems/document-render.md create mode 100644 docs/subsystems/document-render.zh.md create mode 100644 packages/bundle/web-app/tests/document-conversion.e2e.ts create mode 100644 packages/bundle/web-app/tests/fixtures/document-conversion.docx create mode 100644 packages/document/README.i18n.yaml create mode 100644 packages/document/README.md create mode 100644 packages/document/README.zh.md create mode 100644 packages/document/document-render-libreoffice/README.i18n.yaml create mode 100644 packages/document/document-render-libreoffice/README.md create mode 100644 packages/document/document-render-libreoffice/README.zh.md create mode 100644 packages/document/document-render-libreoffice/package.json create mode 100644 packages/document/document-render-libreoffice/src/index.ts create mode 100644 packages/document/document-render-libreoffice/src/output.ts create mode 100644 packages/document/document-render-libreoffice/src/queue.ts create mode 100644 packages/document/document-render-libreoffice/tests/output.spec.ts create mode 100644 packages/document/document-render-libreoffice/tests/provider.spec.ts create mode 100644 packages/document/document-render-libreoffice/tests/queue.spec.ts create mode 100644 packages/document/document-render-libreoffice/tsconfig.json create mode 100644 packages/document/document-render/README.i18n.yaml create mode 100644 packages/document/document-render/README.md create mode 100644 packages/document/document-render/README.zh.md create mode 100644 packages/document/document-render/package.json create mode 100644 packages/document/document-render/src/index.ts create mode 100644 packages/document/document-render/src/types.ts create mode 100644 packages/document/document-render/tsconfig.json create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts create mode 100644 scripts/build-exe-for-python-sdk-office.spec.ts create mode 100644 scripts/build-exe-for-python-sdk-office.ts create mode 100644 scripts/fixtures/python-sdk-office.mjs diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ee3f631f38..2550d722da 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 665364e6d39a78f7ac497198f18ed9aa160a4cbd -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 8b39df71c615dc59f3ef1bf622a5736a70b085b3 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 45191be901f8df048d48798158c83fdfd8134737 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: b76035e6c81841e5eda4dcbcd575479996eff4b9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 665364e6d3..45191be901 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,17 +42,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. + ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -70,6 +72,8 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. +The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (WASM on Linux, native on macOS/Windows), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. + ## Alternatives considered **Bare Node native SEA.** The injected main script must be a single CJS file, and the blob carries no filesystem and no module resolution, so a dynamic import of a bare specifier has nothing to resolve against; the only option is compiling plugins statically into the main script and registering them by hand — bypassing standard module resolution and hardcoding the plugin set, contrary to "configuration decides everything". The final route is in fact "the official SEA foundation + pkg's VFS/module-hook layer"; what was rejected is the bare use, not SEA itself. @@ -84,6 +88,6 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp ## Consequences -**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. +**Bought**: no system Node.js dependency on supported targets; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). +**Paid**: large Office engine resources remain external to the SEA archive, and the executable must travel with its required sidecars; source enters the distribution as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 8b39df71c6..b76035e6c8 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,17 +42,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 + ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -70,6 +72,8 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 +安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(Linux 为 WASM,macOS/Windows 为原生),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 + ## 曾考虑的替代方案 **裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与「配置决定一切」相悖。最终路线实际是「官方 SEA 基础 + pkg 的 VFS/模块钩子层」;否决的是裸用方式,而不是 SEA 本身。 @@ -84,6 +88,6 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 ## 后果 -**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 +**买到的**:受支持目标无需依赖系统 Node.js;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 -**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 +**付出的**:大型 Office 引擎资源保留在 SEA 归档之外,可执行文件必须与必需的伴随文件一起迁移;源码原样进入分发物(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml new file mode 100644 index 0000000000..cb10a38c27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +2026-09-14-independent-libreoffice-kit.md: aaecb7468c16451ed812c9b2441e55e00abf6c98 +2026-09-14-independent-libreoffice-kit.zh.md: 8cea9890596bc53ddcbe346c60c654b49e0362c8 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md new file mode 100644 index 0000000000..aaecb7468c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -0,0 +1,29 @@ +# Agent Note: Independent LibreOffice kit ownership + +Status: implemented + +English | [中文](2026-09-14-independent-libreoffice-kit.zh.md) + +## Problem + +LibreOffice compilation, source patches, platform qualification, and large binary releases have a different maintenance cycle from Harness plugins. Keeping them in the application workspace expands routine CI and couples engine repairs to monorepo package rules. + +## 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 `DocumentRenderer` 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 decision](2026-09-15-platform-office-engines.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. + +## Alternatives considered + +**Co-locate the public API and Core build in Harness.** This synchronizes source changes but makes application maintenance own long engine builds and special package rules. The Cordis provider is the application integration point; the reusable conversion API belongs with its engine tests. + +**Prepare GitHub Release archives before installation.** This requires separate authentication, hashes, decompression, workspace overrides, and distribution repacking. Published npm packages use the application's ordinary dependency installation and platform selection. + +## Consequences + +Changing the kit requires qualifying a release in its own repository and updating the Harness dependency versions and lockfile. A missing required npm package fails installation; Harness does not compile an engine to recover. Native and WASM conversion smokes validate the installed packages, while Desktop and Python checks cover application packaging. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md new file mode 100644 index 0000000000..8cea989059 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 独立 LibreOffice kit 的归属 + +Status: implemented + +[English](2026-09-14-independent-libreoffice-kit.md) | 中文 + +## Problem + +LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布的维护周期不同于 Harness 插件。将它们放在应用工作区会扩大常规 CI,并让引擎修复依赖 monorepo 包规则。 + +## Decision + +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentRenderer` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 + +kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 + +上游 API 将平台引擎声明为可选依赖。[平台引擎决策](2026-09-15-platform-office-engines.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 的精确包标识;无关包和改变后的许可条款仍被拒绝。 + +## Alternatives considered + +**将公开 API 和 Core 构建放在 Harness。** 这能同步源码变更,却让应用维护承担耗时的引擎构建和特殊包规则。Cordis provider 是应用集成点;可复用转换 API 应与引擎测试放在一起。 + +**安装前准备 GitHub Release 归档。** 这需要单独的鉴权、哈希、解压、工作区 overrides 和分发重打包。已发布的 npm 包使用应用常规的依赖安装与平台选择流程。 + +## Consequences + +更新 kit 需要在其独立仓库验证并发布新版本,再更新 Harness 依赖版本和锁文件。必需的 npm 包缺失时安装失败;Harness 不通过编译引擎恢复。原生和 WASM 转换冒烟验证已安装的包,Desktop 与 Python 检查覆盖应用打包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml new file mode 100644 index 0000000000..44528f3fc0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +2026-09-15-bounded-office-rendering.md: 539eb7e4824897861f350f294365f283678a7fee +2026-09-15-bounded-office-rendering.zh.md: 928d93a6500d6c7818f28dd20029648453d9f9cf diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md new file mode 100644 index 0000000000..539eb7e482 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -0,0 +1,33 @@ +# Agent Note: Bounded shared Office rendering + +Status: implemented + +English | [中文](2026-09-15-bounded-office-rendering.zh.md) + +## Problem + +Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. + +## Decision + +The [Host provider](../../../../packages/document/document-render-libreoffice/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 renderer 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. + +Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. + +## Alternatives considered + +**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. + +**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. + +**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. + +**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. + +## Consequences + +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. + +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and renderer replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md new file mode 100644 index 0000000000..928d93a650 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 有界的共享 Office 渲染 + +Status: implemented + +[English](2026-09-15-bounded-office-rendering.md) | 中文 + +## 问题 + +Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 + +## 决策 + +[宿主提供方](../../../../packages/document/document-render-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 + +有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 + +前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 + +## 考虑过的替代方案 + +**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 + +**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 + +**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 + +**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 + +## 后果 + +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 + +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及渲染器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml new file mode 100644 index 0000000000..8fd429f5bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md +2026-09-15-platform-office-engines.md: fa8656eb7505b599f76640077f9b957fd867269c +2026-09-15-platform-office-engines.zh.md: eed1879b9a0bd5fe5056670c414c4db4b81d9d9b diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md new file mode 100644 index 0000000000..fa8656eb75 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -0,0 +1,29 @@ +# Agent Note: One Office engine per platform + +Status: implemented + +English | [中文](2026-09-15-platform-office-engines.zh.md) + +## Problem + +Installing WASM beside a usable native engine adds a second LibreOffice payload to application downloads and installed resources. The original fallback policy in [independent kit ownership](2026-09-14-independent-libreoffice-kit.md) requires that extra payload even on fixed-platform Desktop and Python distributions. + +## Decision + +Harness depends on the kit API and uses its platform-filtered optional engine dependencies. macOS and Windows install their matching ARM64 or x64 native package; Linux installs the shared WASM package. WASM declares Linux as its npm OS. The provider has no direct WASM dependency. Missing native engines on macOS/Windows reject converter creation instead of selecting WASM. + +Python sidecar assembly copies only the target engine and its dependency closure. Wheel packaging and runtime lookup require that engine; the relocated conversion smoke checks the selected backend. Desktop carries the ordinary npm installation in application resources. Engine compilation, qualification, and publication remain in the kit repository. + +## Alternatives considered + +**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every macOS/Windows installation. Fixed-platform distributions require their native engine to be present and tested instead. + +**Remove WASM on every platform.** Linux has no released native engine in this kit family. Keeping the shared WASM package on Linux preserves Office conversion without introducing a new native release target. + +**Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost. + +## Consequences + +macOS/Windows distributions omit the WASM download and expanded assets. A missing native package becomes an installation defect that must be repaired; conversion does not download an engine. Linux retains its WASM resource and font requirements. The kit’s engine-selection tests and actual npm installation tests cover platform selection; Harness sidecar, wheel, and runtime-resolution tests cover packaging requirements. New package bytes require kit qualification and matching dependency integrity records before publication. + +The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md new file mode 100644 index 0000000000..eed1879b9a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 每个平台使用一个 Office 引擎 + +Status: implemented + +[English](2026-09-15-platform-office-engines.md) | 中文 + +## Problem + +在可用的原生引擎之外安装 WASM,会为应用下载和安装资源增加第二份 LibreOffice 载荷。[独立 kit 归属](2026-09-14-independent-libreoffice-kit.zh.md)中最初的回退策略要求固定平台的 Desktop 和 Python 分发物也携带这份额外载荷。 + +## Decision + +Harness 依赖 kit API,并使用它按平台筛选的可选引擎依赖。macOS 和 Windows 安装匹配的 ARM64 或 x64 原生包;Linux 安装共享 WASM 包。WASM 的 npm OS 声明为 Linux。provider 不直接依赖 WASM。macOS/Windows 缺少原生引擎时拒绝创建转换器,不会选择 WASM。 + +Python sidecar 组装仅复制目标引擎及其依赖闭包。wheel 打包和运行时查找要求该引擎存在;迁移后的转换冒烟检查所选 backend。Desktop 在应用资源中携带常规 npm 安装结果。引擎编译、资格验证和发布仍由 kit 仓库负责。 + +## Alternatives considered + +**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个 macOS/Windows 安装。固定平台分发物改为要求其原生引擎存在且经过测试。 + +**在所有平台移除 WASM。** 当前 kit 家族没有已发布的 Linux 原生引擎。在 Linux 保留共享 WASM 包,既能继续转换 Office 文档,也无需引入新的原生发布目标。 + +**将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。 + +## Consequences + +macOS/Windows 分发物省去 WASM 下载和展开资源。原生包缺失成为必须修复的安装缺陷;转换不会下载引擎。Linux 保留 WASM 的资源和字体要求。kit 的引擎选择测试与真实 npm 安装测试覆盖平台选择;Harness 的 sidecar、wheel 和运行时解析测试覆盖打包要求。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 + +[公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d0b5d7c6a4..4bc894fb86 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,6 +36,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | +| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | @@ -145,6 +146,13 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +## LibreOffice conversion kit + +`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. + + ## Development-only npm dependencies External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 27af127d08..edaa3ccd21 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 609ecba962f6efe41abb6e18abcda79aec1692d4 -README.zh.md: 3e2e9c5280484e28f3faec298d928c93c21e717f +README.md: 7d89ad7f5be786c74fe4584a1c29e64a181e7b2e +README.zh.md: 3eb1bcb570ac8d9056573ba5e10e85b9df06546a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 609ecba962..7d89ad7f5b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -114,7 +114,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t ### Runtime file selection -Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. + +The [Office conversion provider](../../packages/document/document-render-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. 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; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 3e2e9c5280..3eb1bcb570 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -114,7 +114,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS ### 运行时文件筛选 -生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 + +[Office 转换提供方](../../packages/document/document-render-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 12c8f4bc55..18048375be 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -137,6 +137,10 @@ async function main(): Promise { throw new Error(`desktop runtime: missing private Host file ${file}`) } } + const officeEngine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) { + throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) + } if (process.platform === 'darwin') { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts index f67d767e81..f22863e34c 100644 --- a/apps/desktop/scripts/prepare-package-set.ts +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto' import { constants, copyFileSync, + globSync, mkdirSync, readFileSync, readdirSync, @@ -13,6 +14,7 @@ import { } from 'node:fs' import { basename, join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import * as yaml from 'js-yaml' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES, @@ -49,13 +51,16 @@ function dependencyNames(manifest: Readonly>, section: s } /** - * Select the complete available first-party dependency closures rooted at dsh and its private Host. + * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages. * @param available - Packed packages indexed by package name. * @returns Selected packages sorted by name. */ export function selectDesktopPackageClosure( available: ReadonlyMap, ): PackedDesktopPackage[] { + const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] } + const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT }) + .map(path => (JSON.parse(readFileSync(join(REPOSITORY_ROOT, path), 'utf8')) as { name: string }).name)) const selected = new Map() const visit = (name: string): void => { if (selected.has(name)) return @@ -65,8 +70,8 @@ export function selectDesktopPackageClosure( for (const section of REQUIRED_DEPENDENCY_SECTIONS) { for (const dependency of dependencyNames(packed.manifest, section)) { if (available.has(dependency)) visit(dependency) - else if (dependency.startsWith('@deepseek-ai/')) { - throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`) + else if (workspaceNames.has(dependency)) { + throw new Error(`desktop package set: ${name} requires unpacked package ${dependency}`) } } } diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 7e5e7d6649..9e6ea293ab 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -23,6 +23,10 @@ export function desktopRuntimeFileExclusion( const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 const name = packageParts.slice(0, nameParts).join('/') const entry = packageParts.slice(nameParts).join('/') + if (name.startsWith('@deepseek-ai/libreoffice-kit-')) { + const engine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` + if (name !== `@deepseek-ai/libreoffice-kit-${engine}`) return 'LibreOffice other platform' + } if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { return 'fs-ext compiler output' } diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts index 1601a0c7f6..85f26a12bb 100644 --- a/apps/desktop/tests/prepare-package-set.spec.ts +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -46,21 +46,38 @@ describe('desktop package-set selection', () => { ]) }) - it('rejects a required internal package absent from the packed release inputs', () => { + it.each([ + '@deepseek-ai/dsh-base', '@deepseek-ai/cordis', '@deepseek-ai/node-addon-system', + ])('rejects required prepared package %s absent from the packed release inputs', (dependency) => { const available = new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { - dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' }, + dependencies: { [dependency]: '^1.0.0' }, })], ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, })], ]) - expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u) + expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked package/u) expect(() => selectDesktopPackageClosure(new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) }) + it('leaves independently published Office packages to npm resolution', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { + '@deepseek-ai/libreoffice-kit': '0.0.1', + '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', + }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')], + ]) + expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', + ]) + }) + it('requires the Desktop Host entry', () => { const files = [ 'package/lib/index.js', diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 837fd54bf1..283a4f5842 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/prebuilds/win32-x64/conpty.pdb', '@koromix/koffi-win32-x64/win32_x64/koffi.lib', '@mixmark-io/domino/test/entities.html', + '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', + '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', ] const retained = [ @@ -44,6 +46,9 @@ it('omits development artifacts while preserving executable modules, assets and '@koromix/koffi-win32-x64/win32_x64/koffi.node', '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', '@img/sharp-win32-x64/lib/libvips-42.dll', + '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', + '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', + '@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt', ] try { const runtime = runtimeFixture(source) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5273440f8a..5503477cb6 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 5db5d11195355330b3c0bf3f58c928bd3533cf85 -capability-seams.zh.md: bfcd60916f615f173bc167623462841e25eca2a2 +capability-seams.md: 8cfa30e16a3ef782719d2360f2e6cbdc4a4560b8 +capability-seams.zh.md: a709b14dd36f0f0b2f7be036cf99603f69631716 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 5db5d11195..8cfa30e16a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,6 +26,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_document_render["document-render"] + svc_documentRender["ctx.documentRender
Office to PDF rendering"] + pkg_document_render_libreoffice["document-render-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -280,6 +283,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions + pkg_document_render --> svc_documentRender + pkg_document_render_libreoffice --> svc_documentRender pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -528,6 +533,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `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.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | | `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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index bfcd60916f..a709b14dd3 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,6 +28,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_document_render["document-render"] + svc_documentRender["ctx.documentRender
Office to PDF rendering"] + pkg_document_render_libreoffice["document-render-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -282,6 +285,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions + pkg_document_render --> svc_documentRender + pkg_document_render_libreoffice --> svc_documentRender pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -530,6 +535,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `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.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 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(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index dca1dd45b8..710d64fdc1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 57cb0c6b5db3177dfd4a777448cbd726a8e0957c -config-catalog.zh.md: b34da72c2f3496748073171aa0dd3cceefb019bc +config-catalog.md: 58eb43a6fea63c8e4ef85e22d45d89c875c62a2e +config-catalog.zh.md: 78de098bee885fb9133264a80fb16c50ed373dc0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57cb0c6b5d..58eb43a6fe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,6 +548,56 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) + + +## `@deepseek-ai/dsh-document-render-libreoffice` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +Source: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) + ## `@deepseek-ai/dsh-experimental-agent-team` @@ -3739,6 +3789,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-document-render` — abstract `DocumentRenderer` ([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b34da72c2f..78de098bee 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,6 +550,56 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) + + +## `@deepseek-ai/dsh-document-render-libreoffice` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +源码: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) + ## `@deepseek-ai/dsh-experimental-agent-team` @@ -3741,6 +3791,7 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-document-render` — 抽象 `DocumentRenderer`([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 5050a296f8..54fba8a4d3 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 01b9620c8e59793bd22cb484dc1c6e40296417c1 -README.zh.md: 6d4cbc8e4f104870d52257bc815d656ce5c29b6c +README.md: 7f39f52de2cd8fca3bbcb9973e7980de979d4385 +README.zh.md: facb05de4078b8033178e40e2ae18424b9183938 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 01b9620c8e..7f39f52de2 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -29,6 +29,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 | +| [document-render.md](document-render.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 | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 6d4cbc8e4f..facb05de40 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -29,6 +29,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 约定 | +| [document-render.md](document-render.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_*` 环境词汇 | diff --git a/docs/subsystems/document-render.i18n.yaml b/docs/subsystems/document-render.i18n.yaml new file mode 100644 index 0000000000..ab3117feae --- /dev/null +++ b/docs/subsystems/document-render.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/document-render.md +document-render.md: f3e49fb45f2cc19956b6385cf7390825fa9d4c60 +document-render.zh.md: 79028858e9e3f95ac1a4618d3b9342b78aa29cf2 diff --git a/docs/subsystems/document-render.md b/docs/subsystems/document-render.md new file mode 100644 index 0000000000..f3e49fb45f --- /dev/null +++ b/docs/subsystems/document-render.md @@ -0,0 +1,62 @@ +# Document rendering + +English | [中文](document-render.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. + +## Ownership + +| Owner | Responsibility | +|---|---| +| [document-render](../../packages/document/document-render/README.md) | `ctx.documentRender`: authorized Office bytes and complete PDF results | +| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | + +## Requests and results + +[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentRenderer.render(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentRenderError`. + +`DocumentRenderPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentRendererGeneration` brands a provider lifetime, and `DocumentRenderKey` brands its content identity; neither opaque value is parsed by consumers. + +| Result field | Meaning | +|---|---| +| `pdf` | Caller-owned `Uint8Array` containing the complete PDF | +| `missingFonts` | Requested document font families unavailable to this conversion | +| `cacheKey` | Opaque renderer generation plus extension/source-content identity | +| `generation` | Provider lifetime; replacement invalidates cached PDF reuse | + +The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. + +## Engine selection and limits + +The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/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. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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 [Host provider configuration](../../packages/document/document-render-libreoffice/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. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.documentRender` — `DocumentRenderer` (abstract seam) + +Load one provider subclass per context; consumers own source authorization. + +```ts cordis-catalog +/** + * Convert Office bytes without modifying the source or writing Session events. + * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +``` + +Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) + diff --git a/docs/subsystems/document-render.zh.md b/docs/subsystems/document-render.zh.md new file mode 100644 index 0000000000..79028858e9 --- /dev/null +++ b/docs/subsystems/document-render.zh.md @@ -0,0 +1,62 @@ +# 文档渲染 + +[English](document-render.md) | 中文 + +[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 + +## 所有权 + +| 所有者 | 职责 | +|---|---| +| [document-render](../../packages/document/document-render/README.zh.md) | `ctx.documentRender`:已授权 Office 字节和完整 PDF 结果 | +| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | + +## 请求和结果 + +[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentRenderer.render(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentRenderError` 拒绝。 + +`DocumentRenderPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentRendererGeneration` 表示提供方生命周期,`DocumentRenderKey` 表示其内容身份;消费者不解析这两种不透明值。 + +| 结果字段 | 含义 | +|---|---| +| `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | +| `missingFonts` | 本次转换无法使用的文档请求字体名称 | +| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 | +| `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | + +提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 + +## 引擎选择和限制 + +外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 + +[Host 提供方配置](../../packages/document/document-render-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.documentRender` — `DocumentRenderer` (abstract seam) + +Load one provider subclass per context; consumers own source authorization. + +```ts cordis-catalog +/** + * Convert Office bytes without modifying the source or writing Session events. + * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +``` + +Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) + diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 634591860d..b7e1d89e10 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 93cce66801d123edd3a4b27e79f025c6935121a2 -workspace.zh.md: 9727c2efa1f0d1abc840f5ce9a377f57af01985a +workspace.md: cf3cf088e5d2ac521df22507bbb41ae3b3be36f0 +workspace.zh.md: 83a9411aaf52559e300009041d7e666a02260ad0 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 93cce66801..cf3cf088e5 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +/** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise + /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 9727c2efa1..83a9411aaf 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +/** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise + /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ea8ccd5133..e126d54d24 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 4fc579f7c58434f13425b933f93f4e96ba513f2e -README.zh.md: 0e78e7ae7567436fa85183a0373ccef9221359ec +README.md: af7ddf7ef8d56073052f08d090a66aabdd991d6d +README.zh.md: afe93298b7f041912acfce05de1b3b4cd0ca4bda diff --git a/packages/README.md b/packages/README.md index 4fc579f7c5..af7ddf7ef8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -55,6 +55,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and model-facing `workflow`/`ralph` tools | | [`webhook/`](webhook/README.md) | Verified external events, trusted rules, and fire-and-forget Workspace Sessions | | [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools | +| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion | | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index 0e78e7ae75..afe93298b7 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -55,6 +55,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow`/`ralph` 工具 | | [`webhook/`](webhook/README.zh.md) | 已验证外部事件、受信规则与即发即弃 Workspace 会话 | | [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 | +| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换 | | [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 | | [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | | [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 | diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 0c9f859e28..7c954c743a 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a -README.zh.md: 8a510fa5a599037364095e221e7ecf6a8c7bb94a +README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91 +README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f0f9cb1532..9da773d652 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -82,6 +82,8 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. +Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte. + ----- diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 8a510fa5a5..56276e5da2 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -82,6 +82,8 @@ kind: "package-reference" 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 +Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。 + ----- diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 0205186879..e099056cd4 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -276,8 +276,22 @@ export class WorkspaceFiles extends TypertRemoteService { */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { + return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + } + + /** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ + async readAllBounded( + workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, + ): Promise { const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const limit = this.config.maxFileBytes + const limit = Math.min(this.config.maxFileBytes, maxBytes) if (info.size !== undefined && info.size > limit) { throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) } diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index bf331b8f19..8bf2470241 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -51,6 +51,17 @@ describe('workspaceFiles.readAll', () => { }) }) +it('applies the smaller Host reservation before reading a complete file', async () => { + await writeFile(join(harness.workspace, 'reserved'), '1234') + const read = vi.spyOn(harness.ctx.fs, 'readByteRange') + const files = harness.endpoint({ maxFileBytes: 8 }) + expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal()))) + .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } }) + expect(read).not.toHaveBeenCalled() + expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4) + expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 }) +}) + describe('workspaceFiles.readRelated', () => { it('resolves relative paths from the base file directory on the Host', async () => { await mkdir(join(harness.workspace, 'nested')) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 54536cd33e..807ca3fb09 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: c35bdf219c4c231e2ffde73a592b04070f96a4bc -README.zh.md: 9a4047525da16cfb8249e4732fc2102f95b9d89f +README.md: 0057516fb5bfba2708923e08af20f6e59f53f772 +README.zh.md: 1be494520d18311cae1f8dfca00c682b597f889d diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index c35bdf219c..0057516fb5 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, the bash variable, and the readiness announcements. +The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-render` row mounts one lazy [Office conversion provider](../../document/document-render-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 9a4047525d..1be494520d 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -本组合包是一份 patch 加一个运行时粘合插件。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的 workspace 与 message-feedback 行使用共享的 `storageDomain` 服务。patch 重述 base 刻意省略的表层专属值,插入仅 Web 使用的宿主行与浏览器名录,然后把 agent 层改由 preset 承载;粘合插件负责 dist 服务、信任采样、提示词段落、bash 变量与就绪宣告。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-render` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-render-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d216de7755..0a5aba45a5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,6 +225,9 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' + - id: document-render + name: '@deepseek-ai/dsh-document-render-libreoffice' + # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. - id: ui-sidebar-documentpreview diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 263a507063..5e8ee68258 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -121,7 +121,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^" + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", + "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/dsh-document-render-libreoffice": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts new file mode 100644 index 0000000000..3cab818548 --- /dev/null +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -0,0 +1,76 @@ +/** The shipped shared provider converts Office bytes without presentation plugins. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +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 { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-render-libreoffice' +import { expect, it, onTestFinished } from 'vitest' + +it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-conversion-loader-')) + const ctx = new Context() + onTestFinished(async () => { + try { await ctx.fiber.dispose() } + finally { await rm(directory, { recursive: true, force: true }) } + }) + 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-document-render-libreoffice') + expect(rows.map(row => row.id)).toEqual(['document-render']) + const configured = applyEntryPatches(rows, [{ id: 'document-render', config: { maxConcurrentConversions: 1 } }], + (message) => { throw new Error(message) }) + const configPath = join(directory, 'cordis.yml') + await writeFile(configPath, JSON.stringify(configured)) + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + // The Loader resolves the same source-plane module namespace as the test. + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier !== '@deepseek-ai/dsh-document-render-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + return LibreOfficeProvider + }, + } as unknown as NonNullable + 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 === 'document-render')! + await entry.fiber!.await() + expect(ctx.get('documentRenderController')).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): DocumentRenderRequest => ({ + extension: 'docx', priority: 'foreground', + source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, + async read(signal, maxBytes) { + signal.throwIfAborted() + expect(bytes.length).toBeLessThanOrEqual(maxBytes) + reads++ + return { bytes: Uint8Array.from(bytes), version: 'fixture' } + }, + }, + }) + const provider = ctx.documentRender + const [first, second] = await Promise.all([provider.render(request('first')), provider.render(request('second'))]) + expect(reads).toBe(2) + expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-') + expect(first.cacheKey).toBe(second.cacheKey) + expect(first.pdf).toEqual(second.pdf) + expect(first.pdf).not.toBe(second.pdf) + expect(first.missingFonts).not.toBe(second.missingFonts) + first.pdf.fill(0) + first.missingFonts.push('caller-owned') + const cached = await provider.render(request('first')) + expect(reads).toBe(2) + expect(cached.pdf).toEqual(second.pdf) + expect(cached.missingFonts).toEqual(second.missingFonts) + await ctx.fiber.dispose() + expect(Buffer.from(cached.pdf).subarray(0, 5).toString()).toBe('%PDF-') + await expect(provider.render(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) +}) diff --git a/packages/bundle/web-app/tests/fixtures/document-conversion.docx b/packages/bundle/web-app/tests/fixtures/document-conversion.docx new file mode 100644 index 0000000000000000000000000000000000000000..3cb118c895bd055950c97a22e474962c89522ae9 GIT binary patch literal 869 zcmWIWW@Zs#U|`??V#R>(-VLRPfUGh`1_ogu9qpW-SCX1n5+71okXjt8SCN}jJ7Fi^ zAp@Sa_@$@1RtPdPFVZ={#j~ej>aJWDy`y@^Z%;1b<&b#$|A&q~-*u0em^oi#R{E)& zV~i+&GU2qq$@Hb1Cl@|3+$s<#zfpCDpPKQ7gWQ#pr!Op=)@^EfBU;mJ`kpl5nx#UK zVMkvj%0x^(B(e6QMr8Zs_4XQ%)fb*Qxx3w3xN-_F*FGV(r{)i4-kz(W8XUawxJq=% zP1ffVvQ{*|i}joKKYQOD$yvuTs-CxRo6)3}_BcBI^!7&=eq8@sQ2+fCTL6le6RY)| zJAq#P1jO7x9AA{0Q>?ECB74tU^D!w3usv8Uz_(#@RD{HWO*gMqFftuh*6Zhy`1&m< zV$HEbMbGBf)Vs|oylOk`j?ZWtE}iw*`GZg1K7f5neo5}VZeV?R|Za(~-9kL|Q0 z=Z}iOy$q->-p%^U2NX$Kz(^7V(&hO@Df%h-$)&l#m;yynZotK?!v;KmL?`pFxU``+ z>5@bIH-ma$Urh zV7=IU54YgTf@F_-9+HKTCWQUCb*fq+ zaq8B@*LODlDoFQni&3~L{b-fJ=guFT%(2|%(l70fUyKYfeAs_@jnJ%zyEsxNuidfC zIO^uKM_2xf{x7<}FEj3y>FO`*+b`w6GuOSN*KoXiSABptBa;X-?sN(CH5fDig8*wP wMK=IFmLb|17#bKq1DQy1j;;|sun`)U0`0~K`T%cMHjol#AlwP0r!j$e0D&z`7ytkO literal 0 HcmV?d00001 diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml new file mode 100644 index 0000000000..d31937a0b5 --- /dev/null +++ b/packages/document/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/document/README.md +README.md: 4ccf8e9c7267b7442ee08a218b51e50a4909aae3 +README.zh.md: c91beff7d3f6a028d9a1553308714c0328ab35de diff --git a/packages/document/README.md b/packages/document/README.md new file mode 100644 index 0000000000..4ccf8e9c72 --- /dev/null +++ b/packages/document/README.md @@ -0,0 +1,51 @@ +--- +description: "Package map for Host Office conversion and reusable PDF results." +kind: "package-group" +--- + +# document/ — Office rendering + +English | [中文](README.zh.md) + +## Summary + +Convert authorized Office files to reusable PDFs on the Host. The rendering family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +Each package owns its configuration and lifetime rules; the subsystem reference describes their shared rendering operation. + +| Package | Role | ctx key | +|---|---|---| +| [document-render](document-render/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentRender` | +| [document-render-libreoffice](document-render-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentRender` | + +----- + + +## Related documentation + +Consumers own source authorization and presentation. + +- [Document rendering](../../docs/subsystems/document-render.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. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md new file mode 100644 index 0000000000..c91beff7d3 --- /dev/null +++ b/packages/document/README.zh.md @@ -0,0 +1,51 @@ +--- +description: "Host Office 转换和可复用 PDF 结果的包索引。" +kind: "package-group" +--- + +# document/ — Office 渲染 + +[English](README.md) | 中文 + +## 摘要 + +在宿主上将已授权的 Office 文件转换为可复用的 PDF。渲染包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +每个包负责自身配置和生命周期规则;子系统参考描述共享渲染操作。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [document-render](document-render/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentRender` | +| [document-render-libreoffice](document-render-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentRender` | + +----- + + +## 相关文档 + +消费者负责源文件授权与展示。 + +- [文档渲染](../../docs/subsystems/document-render.zh.md) — 共享操作和生成的服务参考。 +- [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 +- [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 + + +## 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render-libreoffice/README.i18n.yaml b/packages/document/document-render-libreoffice/README.i18n.yaml new file mode 100644 index 0000000000..1fde4e33b9 --- /dev/null +++ b/packages/document/document-render-libreoffice/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/document/document-render-libreoffice/README.md +README.md: 3315498c928a5613809ad3f75a1d9fe3e4bc07d2 +README.zh.md: e39f5824d5f969d997914848ebdd6dc767186dc7 diff --git a/packages/document/document-render-libreoffice/README.md b/packages/document/document-render-libreoffice/README.md new file mode 100644 index 0000000000..3315498c92 --- /dev/null +++ b/packages/document/document-render-libreoffice/README.md @@ -0,0 +1,97 @@ +--- +description: "Host Office conversion with the independently published LibreOffice kit." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render-libreoffice + +English | [中文](README.zh.md) + +## Summary + +Convert Office documents to PDFs on the Host computer. macOS and Windows use their matching native LibreOffice engine; Linux uses 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. + +## 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) + +----- + + +## Use this package + +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-render`. Independent compositions mount `@deepseek-ai/dsh-document-render-libreoffice` as a `cordis.yml` row. + +The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. + +| 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-document-render-libreoffice) 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 provider retains successful PDFs by renderer 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, base64 transport expansion, caller-retained results, or PDF.js page rendering. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +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. No runtime invariant companion is published because active operations and scratch cleanup have one lifetime owner. + +
+ +----- + + +## Further Exploration + +- [Rendering service](../document-render/README.md) — input and result ownership. +- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. + +----- + + +## 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 + + + +- 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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/document-render-libreoffice/README.zh.md b/packages/document/document-render-libreoffice/README.zh.md new file mode 100644 index 0000000000..e39f5824d5 --- /dev/null +++ b/packages/document/document-render-libreoffice/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文档。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render-libreoffice + +[English](README.md) | 中文 + +## 概述 + +在宿主计算机上将 Office 文档转换为 PDF。macOS 和 Windows 使用匹配的原生 LibreOffice 引擎;Linux 使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +[Web bundle](../../bundle/web-app/README.zh.md)以 `document-render` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-render-libreoffice`。 + +此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `maxConcurrentConversions` | `2` | 最大活跃转换器数量;排队调用仍可取消。 | +| `timeoutMs` | `60000` | 获取转换器后的转换期限。 | +| `maxInputBytes` | `52428800` | 最大源字节数。 | +| `maxOutputBytes` | `104857600` | 最大完整 PDF 字节数。 | +| `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | +| `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | + +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-render-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 + +提供方按渲染 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 + +准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 + +默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、base64 传输膨胀、调用方保留的结果或 PDF.js 页面渲染。 + +----- + + +## 理解实现 + +
+实现内部细节 — 点击展开 + +引擎元数据无效、必需资源缺失和转换错误会拒绝请求,不会切换引擎。共享的 WASM 引擎使用 LibreOffice 的 CPU 图像过滤器。原生转换使用独立的平台引擎。 + +每个并发槽按需创建并复用一个 kit 转换器。提供方将已授权输入写入私有临时目录,读取有大小上限的普通 PDF 文件,并在完成前删除目录。取消已准入的读取方不会阻塞后续排队工作。读取方取消只会释放该读取方;最后一个读取方和提供方卸载会取消共享工作。卸载向未完成读取方报告 `unavailable`,并等待转换及转换器清理结束。活跃操作和临时目录清理由同一生命周期负责,因此不发布运行时不变量伴随入口。 + +
+ +----- + + +## 延伸阅读 + +- [渲染服务](../document-render/README.zh.md) — 输入和结果所有权。 +- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 + +----- + + +## 模型体验 + +无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 + +#### KV Cache effect + +无;转换不会构造或修改模型请求。 + +## 已知限制与延后工作 + + + +- 转换保真度和已安装的原生/WASM 资产由 `@deepseek-ai/libreoffice-kit` 负责;此提供方不查找系统 LibreOffice,也不在运行时下载引擎。 +- `timeoutMs` 仅在 kit 开始转换时计时,不限制队列等待时间。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render-libreoffice/package.json b/packages/document/document-render-libreoffice/package.json new file mode 100644 index 0000000000..3adb8d4203 --- /dev/null +++ b/packages/document/document-render-libreoffice/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-document-render-libreoffice", + "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", + "version": "0.1.6-alpha.1", + "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/document-render-libreoffice" + }, + "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:^", + "@deepseek-ai/dsh-document-render": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/libreoffice-kit": "0.0.1", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/document/document-render-libreoffice/src/index.ts b/packages/document/document-render-libreoffice/src/index.ts new file mode 100644 index 0000000000..21296d11f0 --- /dev/null +++ b/packages/document/document-render-libreoffice/src/index.ts @@ -0,0 +1,169 @@ +/** Host LibreOffice kit provider with reusable converters and private disk input/output. */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' +import { DocumentRenderer, DocumentRenderError, DocumentRendererGeneration, type DocumentExtension, type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import z from '@deepseek-ai/schemastery' +import { readPdf } from './output.ts' +import { RenderQueue } from './queue.ts' + +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} + +/** Deployment defaults resolved before provider construction. */ +export const Config: z, Config> = z.object({ + maxConcurrentConversions: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(2), + maxQueuedJobs: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32), + maxSourceBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(104857600), + maxBackgroundConversions: z.natural().min(0).max(Number.MAX_SAFE_INTEGER).default(1), + maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxCachedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(134217728), + maxSourceEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(64), + timeoutMs: z.natural().min(1).max(2_147_483_647).default(60_000), + maxInputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(50 * 1024 * 1024), + maxOutputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(100 * 1024 * 1024), + maxImageResolution: z.natural().min(1).max(2_147_483_647).default(192), + maxArchiveEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(10_000), + maxUncompressedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250 * 1024 * 1024), + fontDirectories: z.array(z.string().min(1)).extra('default', undefined), + fontFallbacks: z.array(z.array(z.string().pattern(/\S/)).min(2)).extra('default', undefined), + maxFontFiles: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(20_000), + maxFontFileBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(256 * 1024 * 1024), + maxLoadedFontBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(512 * 1024 * 1024), +}) + +interface Slot { + busy: boolean + converter?: Promise +} + +/** A provider lifetime owns all converters, queued calls, and temporary files. */ +export class LibreOfficeRenderer extends DocumentRenderer { + static Config = Config + readonly generation = DocumentRendererGeneration(randomUUID()) + private readonly slots: Slot[] = [] + private readonly queue: RenderQueue + private readonly options: ConverterOptions + + /** + * @param ctx - owning Host context. + * @param config - resolved rendering, font, and concurrency limits. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx) + if (config.fontDirectories?.some(path => !isAbsolute(path))) throw new Error('fontDirectories must contain absolute paths.') + if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.') + const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, + maxArchiveEntries, maxUncompressedBytes, maxFontFiles, maxFontFileBytes, maxLoadedFontBytes } = config + this.options = { timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, maxArchiveEntries, maxUncompressedBytes, + maxFontFiles, maxFontFileBytes, maxLoadedFontBytes, + ...(fontDirectories === undefined ? {} : { fontDirectories }), + ...(fontFallbacks === undefined ? {} : { fontFallbacks }), + } + this.queue = new RenderQueue(config, this.generation, (bytes, extension, signal) => this.convert(bytes, extension, signal)) + ctx.effect(() => async () => { + await this.queue.dispose() + const results = await Promise.allSettled(this.slots.map(async (slot) => { + const converter = await slot.converter + await converter?.dispose() + })) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length > 0) throw new AggregateError(failures.map((result): unknown => result.reason), 'LibreOffice converter disposal failed.') + }) + } + + render(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + return this.queue.read(request, signal) + } + + private async convert(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + signal.throwIfAborted() + let slot = this.slots.find(candidate => !candidate.busy) + if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } + slot.busy = true + let directory: string | undefined + try { + if (slot.converter === undefined) { + slot.converter = createConverter(this.options).catch((error: unknown) => { + delete slot.converter + throw error + }) + } + const converter = await slot.converter + signal.throwIfAborted() + directory = await mkdtemp(join(tmpdir(), 'dsh-document-render-')) + const inputPath = join(directory, `source.${extension}`) + const outputPath = join(directory, 'converted.pdf') + await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) + signal.throwIfAborted() + const result = await converter.render({ inputPath, outputPath }, signal) + signal.throwIfAborted() + let pdf: Uint8Array + try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } + catch (cause) { + if (cause instanceof DocumentRenderError) throw cause + throw new DocumentRenderError('invalid-output', 'The converter PDF could not be read.', { cause }) + } + signal.throwIfAborted() + return { pdf, missingFonts: result.missingFonts } + } catch (cause) { + signal.throwIfAborted() + if (cause instanceof DocumentRenderError) throw cause + const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined + switch (code) { + case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': + case 'invalid-output': case 'timeout': case 'unavailable': + throw new DocumentRenderError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new DocumentRenderError('failed', 'LibreOffice conversion failed.', { cause }) + } + } finally { + try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } + finally { slot.busy = false } + } + } +} + +export default LibreOfficeRenderer diff --git a/packages/document/document-render-libreoffice/src/output.ts b/packages/document/document-render-libreoffice/src/output.ts new file mode 100644 index 0000000000..863c681020 --- /dev/null +++ b/packages/document/document-render-libreoffice/src/output.ts @@ -0,0 +1,39 @@ +/** Read only the bounded, regular PDF created inside a private task directory. */ +import { constants } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' + +/** + * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. + * @param path - kit output path in the provider-owned scratch directory. + * @param limit - inclusive PDF byte limit. + * @param signal - conversion lifetime. + * @returns complete bytes independent of the scratch file. + */ +export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { + signal.throwIfAborted() + const entry = await lstat(path) + if (!entry.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + const info = await file.stat() + if (!info.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + const bytes = Buffer.alloc(info.size + 1) + let length = 0 + while (length < bytes.length) { + signal.throwIfAborted() + const read = await file.read(bytes, length, bytes.length - length, null) + if (read.bytesRead === 0) break + length += read.bytesRead + } + signal.throwIfAborted() + if (length > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + const pdf = bytes.subarray(0, length) + if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) + || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { + throw new DocumentRenderError('invalid-output', 'The converter did not produce a complete PDF.') + } + return pdf + } finally { await file.close() } +} diff --git a/packages/document/document-render-libreoffice/src/queue.ts b/packages/document/document-render-libreoffice/src/queue.ts new file mode 100644 index 0000000000..affcebfb3f --- /dev/null +++ b/packages/document/document-render-libreoffice/src/queue.ts @@ -0,0 +1,232 @@ +/** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ +import { createHash } from 'node:crypto' +import { DocumentRenderError, DocumentRenderKey, type DocumentExtension, type DocumentRendererGeneration, + type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import type { Config } from './index.ts' + +type Converted = Pick +type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise +interface Reader { + job: Job + readonly source: string + readonly priority: DocumentRenderRequest['priority'] + readonly resolve: (value: DocumentRenderResult) => void + readonly reject: (error: unknown) => void + readonly cleanup: () => void +} +interface Job { + readonly request: DocumentRenderRequest + readonly controller: AbortController + readonly readers: Set + readonly sources: Set + priority: DocumentRenderRequest['priority'] + state: 'queued' | 'running' | 'finished' +} + +/** One renderer generation owns every queued source, conversion, reader, and retained PDF. */ +export class RenderQueue { + private readonly ready = new Map() + private readonly aliases = new Map() + private readonly sources = new Map() + private readonly digests = new Map() + private readonly queue: Job[] = [] + private readonly tasks = new Set>() + private readonly jobs = new Set() + private cachedBytes = 0 + private sourceBytes = 0 + private running = 0 + private background = 0 + private readers = 0 + private disposed = false + + /** + * @param config - validated queue, source, reader, and completed-result limits. + * @param generation - provider lifetime; prevents reuse after engine or font replacement. + * @param convert - executes one admitted conversion and settles after scratch cleanup. + */ + constructor(private readonly config: Config, private readonly generation: DocumentRendererGeneration, + private readonly convert: Convert) {} + + /** + * Admit metadata before loading source bytes and share conversion across authorized readers. + * @param request - already-authorized metadata and deferred bounded source read. + * @param signal - this reader's cancellation; the final reader cancels shared work. + * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. + */ + async read(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (this.disposed) throw this.unavailable() + if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { + throw new DocumentRenderError('input-too-large', 'The Office source exceeds maxInputBytes.') + } + const source = JSON.stringify([request.source.key, request.source.version, request.extension]) + const alias = this.aliases.get(source) + const cached = alias === undefined ? undefined : this.ready.get(alias) + if (cached !== undefined) { + this.ready.delete(cached.cacheKey) + this.ready.set(cached.cacheKey, cached) + this.aliases.delete(source) + this.aliases.set(source, cached.cacheKey) + return this.copy(cached) + } + const readerLimit = request.priority === 'background' ? this.config.maxReaders - 1 : this.config.maxReaders + if (this.readers >= readerLimit) throw this.busy() + if (request.priority === 'background' && this.config.maxBackgroundConversions === 0) throw this.busy() + let job = this.sources.get(source) + if (job === undefined) { + if (this.queue.length >= this.config.maxQueuedJobs) { + const obsolete = request.priority === 'foreground' ? this.queue.find(item => item.priority === 'background') : undefined + if (obsolete === undefined) throw this.busy() + this.fail(obsolete, this.busy()) + } + job = { request, priority: request.priority, controller: new AbortController(), readers: new Set(), sources: new Set([source]), state: 'queued' } + this.sources.set(source, job) + this.jobs.add(job) + this.queue.push(job) + } + const shared = job + if (request.priority === 'foreground') shared.priority = 'foreground' + this.readers++ + const promise = new Promise((resolve, reject) => { + const abort = (): void => { + this.release(reader) + const reason: unknown = signal?.reason + reject(reason instanceof Error ? reason : new Error('Document rendering cancelled', { cause: reason })) + if (reader.job.readers.size === 0) this.cancel(reader.job) + // Cancellation or demotion can unblock queued background work. + this.drain() + } + const reader: Reader = { job: shared, source, priority: request.priority, resolve, reject, cleanup: () => signal?.removeEventListener('abort', abort) } + shared.readers.add(reader) + signal?.addEventListener('abort', abort, { once: true }) + }) + this.drain() + return promise + } + + /** Cancel all readers and wait until actual reads, conversions, and scratch cleanup finish. */ + async dispose(): Promise { + this.disposed = true + for (const job of this.jobs) this.fail(job, this.unavailable()) + this.ready.clear() + this.aliases.clear() + this.cachedBytes = 0 + await Promise.allSettled(this.tasks) + } + + private busy(): DocumentRenderError { return new DocumentRenderError('busy', 'The document renderer has reached its admission limit.') } + private unavailable(): DocumentRenderError { return new DocumentRenderError('unavailable', 'The document renderer is unavailable.') } + private copy(result: DocumentRenderResult): DocumentRenderResult { + return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } + } + private release(reader: Reader): void { + reader.job.readers.delete(reader) + if (reader.job.state === 'queued') { + reader.job.priority = [...reader.job.readers].some(other => other.priority === 'foreground') ? 'foreground' : 'background' + } + if (![...reader.job.readers].some(other => other.source === reader.source)) { + reader.job.sources.delete(reader.source) + if (this.sources.get(reader.source) === reader.job) this.sources.delete(reader.source) + } + reader.cleanup() + this.readers-- + } + private cancel(job: Job): void { + job.controller.abort() + if (job.state === 'queued') { + this.queue.splice(this.queue.indexOf(job), 1) + job.state = 'finished' + this.jobs.delete(job) + } + } + private fail(job: Job, error: unknown): void { + for (const reader of job.readers) { this.release(reader); reader.reject(error) } + this.cancel(job) + } + private reservation(job: Job): number { return Math.max(1, job.request.source.bytes ?? this.config.maxInputBytes) } + + private drain(): void { + while (!this.disposed && this.running < this.config.maxConcurrentConversions) { + const eligible = (job: Job): boolean => this.sourceBytes + this.reservation(job) <= this.config.maxSourceBytes + && (job.priority === 'foreground' || this.background < Math.min(this.config.maxBackgroundConversions, Math.max(1, this.config.maxConcurrentConversions - 1))) + const foregroundWaiting = this.queue.some(item => item.priority === 'foreground') + const job = this.queue.find(item => (!foregroundWaiting || item.priority === 'foreground') && eligible(item)) + if (job === undefined) return + this.queue.splice(this.queue.indexOf(job), 1) + job.state = 'running' + const background = job.priority === 'background' + const reserved = this.reservation(job) + this.running++ + if (background) this.background++ + this.sourceBytes += reserved + const task = this.execute(job, reserved).catch((error: unknown) => { this.fail(job, error) }).finally(() => { + this.running-- + if (background) this.background-- + this.sourceBytes -= reserved + job.state = 'finished' + this.jobs.delete(job) + this.tasks.delete(task) + this.drain() + }) + this.tasks.add(task) + } + } + + private async execute(job: Job, reserved: number): Promise { + const signal = job.controller.signal + signal.throwIfAborted() + const input = await job.request.source.read(signal, reserved) + signal.throwIfAborted() + if (input.version !== job.request.source.version) throw new DocumentRenderError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new DocumentRenderError('input-too-large', 'The source exceeds its reserved read capacity.') + const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') + const key = DocumentRenderKey(`${this.generation}:${digest}`) + const cached = this.ready.get(key) + if (cached !== undefined) { + this.ready.delete(key) + this.ready.set(key, cached) + this.finish(job, cached) + return + } + const existing = this.digests.get(key) + if (existing !== undefined && !existing.controller.signal.aborted) { + if (job.priority === 'foreground') existing.priority = 'foreground' + for (const reader of job.readers) { reader.job = existing; existing.readers.add(reader) } + job.readers.clear() + for (const source of job.sources) { existing.sources.add(source); this.sources.set(source, existing) } + job.sources.clear() + return + } + this.digests.set(key, job) + try { + const converted = await this.convert(input.bytes, job.request.extension, signal) + signal.throwIfAborted() + const result = { ...converted, cacheKey: key, generation: this.generation } + this.retain(result) + this.finish(job, result) + } finally { if (this.digests.get(key) === job) this.digests.delete(key) } + } + + private finish(job: Job, result: DocumentRenderResult): void { + for (const source of job.sources) this.sources.delete(source) + if (this.ready.has(result.cacheKey)) { + for (const source of job.sources) { + this.aliases.delete(source) + this.aliases.set(source, result.cacheKey) + while (this.aliases.size > this.config.maxSourceEntries) this.aliases.delete(this.aliases.keys().next().value as string) + } + } + for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } + } + private retain(result: DocumentRenderResult): void { + if (result.pdf.byteLength > this.config.maxCachedBytes) return + while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { + const [key, oldest] = this.ready.entries().next().value as [DocumentRenderKey, DocumentRenderResult] + this.ready.delete(key) + this.cachedBytes -= oldest.pdf.byteLength + for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) + } + this.ready.set(result.cacheKey, result) + this.cachedBytes += result.pdf.byteLength + } +} diff --git a/packages/document/document-render-libreoffice/tests/output.spec.ts b/packages/document/document-render-libreoffice/tests/output.spec.ts new file mode 100644 index 0000000000..ad897ef5f0 --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/output.spec.ts @@ -0,0 +1,64 @@ +/** PDF output checks use opened-file metadata and enforce byte bounds during reads. */ +import { appendFile, mkdtemp, rm, stat, writeFile, type FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { readPdf } from '../src/output.ts' + +const io = vi.hoisted(() => ({ opened: undefined as ((file: FileHandle) => Promise) | undefined })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters): ReturnType { + const file = await actual.open(...args) + await io.opened?.(file) + return file + }, + } +}) + +afterEach(() => { io.opened = undefined; vi.restoreAllMocks() }) + +const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') + +async function output(): Promise<{ directory: string; path: string; handles: FileHandle[] }> { + const directory = await mkdtemp(join(tmpdir(), 'dsh-render-output-')) + const handles: FileHandle[] = [] + onTestFinished(async () => { + try { await Promise.all(handles.map(file => file.close())) } + finally { await rm(directory, { recursive: true, force: true }) } + }) + const path = join(directory, 'preview.pdf') + await writeFile(path, pdf) + return { directory, path, handles } +} + +it('closes an opened output that is not a regular file despite earlier path metadata', async () => { + const { directory, path, handles } = await output() + const directoryInfo = await stat(directory) + io.opened = async (file) => { + handles.push(file) + // Directory handles cannot be opened on Windows; fstat supplies the same rejection input. + vi.spyOn(file, 'stat').mockResolvedValueOnce(directoryInfo) + } + await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'invalid-output' }) + expect(handles).toHaveLength(1) + expect(handles[0]!.fd).toBe(-1) +}) + +it('rejects output that grows past the limit after opened-file metadata is read', async () => { + const { path, handles } = await output() + io.opened = async (file) => { + handles.push(file) + const inspect = file.stat.bind(file) + vi.spyOn(file, 'stat').mockImplementationOnce(async () => { + const info = await inspect() + await appendFile(path, 'extra output') + return info + }) + } + await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'output-too-large' }) + expect(handles).toHaveLength(1) + expect(handles[0]!.fd).toBe(-1) +}) diff --git a/packages/document/document-render-libreoffice/tests/provider.spec.ts b/packages/document/document-render-libreoffice/tests/provider.spec.ts new file mode 100644 index 0000000000..aa36e95d2c --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/provider.spec.ts @@ -0,0 +1,269 @@ +/** Disk output, resource bounds, and cancellation around the external kit. */ +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { Context } from '@deepseek-ai/cordis' +import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import LibreOfficeRenderer, { Config } from '../src/index.ts' + +const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) +vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) + +const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') +const input = new Uint8Array([80, 75, 3, 4]) +const request: DocumentRenderRequest = { extension: 'docx', priority: 'foreground', source: { + key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, + read: async () => ({ bytes: input, version: 'v1' }), +} } +function distinct(index: number): DocumentRenderRequest { + return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), + read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } +} +let ctx: Context +let render: ReturnType> +let dispose: ReturnType> + +beforeEach(() => { + ctx = new Context() + render = vi.fn().mockImplementation(async ({ outputPath }) => { + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: ['Missing Serif'] } + }) + dispose = vi.fn().mockResolvedValue(undefined) + kit.create.mockReset().mockImplementation(async () => ({ backend: 'native', render, dispose })) +}) +afterEach(async () => { await ctx.fiber.dispose() }) + +async function mount(config: Partial = {}): Promise { + await ctx.plugin(LibreOfficeRenderer, config) + return ctx.documentRender as LibreOfficeRenderer +} + +it('preserves the kit font defaults when font configuration is omitted', async () => { + const renderer = await mount() + await renderer.render(request) + expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontDirectories') + expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontFallbacks') + expect(Config({ fontDirectories: [] }).fontDirectories).toEqual([]) + expect(Config({ fontFallbacks: [] }).fontFallbacks).toEqual([]) + expect(() => Config({ fontDirectories: [''] })).toThrow() +}) + +it.each([ + [[]], + [['sans-serif']], + [['sans-serif', '']], + [['sans-serif', ' \t\n ']], +])('rejects invalid font preference groups %j before creating a converter', (...fontFallbacks) => { + expect(() => Config({ fontFallbacks })).toThrow() + expect(kit.create).not.toHaveBeenCalled() +}) + +it('passes configured limits to the kit, reuses the converter, and removes all scratch files', async () => { + const config = Config({ maxConcurrentConversions: 1, maxInputBytes: 4, maxOutputBytes: pdf.length, + maxImageResolution: 144, maxArchiveEntries: 32, maxUncompressedBytes: 4096, + timeoutMs: 321, fontDirectories: [], fontFallbacks: [['sans-serif', 'Arial'], ['serif', 'Times New Roman']], + maxFontFiles: 12, maxFontFileBytes: 4096, maxLoadedFontBytes: 8192 }) + const renderer = await mount(config) + let scratch = '' + render.mockImplementation(async ({ inputPath, outputPath }) => { + scratch = dirname(inputPath) + expect(await readFile(inputPath)).toEqual(Buffer.from(input)) + if (process.platform !== 'win32') { + expect((await stat(scratch)).mode & 0o777).toBe(0o700) + expect((await stat(inputPath)).mode & 0o777).toBe(0o600) + } + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: ['Missing Serif'] } + }) + const result = await renderer.render(request) + expect(result).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) + await renderer.render(request) + const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source, + maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached, + maxSourceEntries: _aliases, ...options } = config + expect(kit.create).toHaveBeenCalledExactlyOnceWith(options) + await ctx.fiber.dispose() + expect(dispose).toHaveBeenCalledOnce() + expect(result.pdf).toEqual(Uint8Array.from(pdf)) +}) + +it('refuses an oversized input before allocating a converter', async () => { + const renderer = await mount({ maxInputBytes: 3 }) + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'input-too-large' }) + expect(kit.create).not.toHaveBeenCalled() +}) + +it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const)('rejects %s kit output and removes its directory', async (kind) => { + const renderer = await mount({ maxOutputBytes: pdf.length }) + let scratch = '' + render.mockImplementation(async ({ inputPath, outputPath }) => { + scratch = dirname(inputPath) + if (kind === 'directory') await mkdir(outputPath) + else if (kind === 'not-pdf') await writeFile(outputPath, 'engine diagnostic') + else if (kind === 'incomplete') await writeFile(outputPath, '%PDF-1.7\n') + else if (kind === 'too-large') await writeFile(outputPath, Buffer.concat([pdf, pdf])) + return { backend: 'wasm', missingFonts: [] } + }) + await expect(renderer.render(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('retries initialization after a failed kit factory and reports unclassified errors', async () => { + const renderer = await mount() + kit.create.mockRejectedValueOnce(Object.assign(new Error('absent asset'), { code: 'unavailable' })) + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) + expect(await renderer.render(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + expect(kit.create).toHaveBeenCalledTimes(2) + render.mockRejectedValueOnce(new Error('unexpected engine failure')) + await expect(renderer.render(distinct(9))).rejects.toMatchObject({ code: 'failed' }) +}) + +it.each([ + 'input-too-large', 'output-too-large', 'invalid-document', 'unsupported-format', 'invalid-output', 'timeout', 'unavailable', +] as const)('preserves the kit %s failure for the document consumer', async (code) => { + const renderer = await mount() + const cause = Object.assign(new Error('conversion failed'), { code }) + render.mockRejectedValueOnce(cause) + await expect(renderer.render(request)).rejects.toMatchObject({ code, cause }) +}) + +it('cancels a queued caller without starting or stopping another conversion', async () => { + const renderer = await mount({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + render.mockImplementationOnce(async ({ outputPath }, signal) => { + entered.resolve(signal!) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'wasm', missingFonts: [] } + }) + const first = renderer.render(request) + try { + const activeSignal = await entered.promise + const controller = new AbortController() + const second = renderer.render(request, controller.signal) + const rejected = expect(second).rejects.toMatchObject({ name: 'AbortError' }) + await Promise.resolve(undefined) + controller.abort() + await rejected + expect(render).toHaveBeenCalledOnce() + expect(activeSignal.aborted).toBe(false) + } finally { release.resolve(undefined); await first } + expect(await renderer.render(request)).toHaveProperty('pdf') +}) + +it('bounds active converters and resumes queued work when a slot becomes free', async () => { + const renderer = await mount({ maxConcurrentConversions: 2 }) + const both = Promise.withResolvers() + const release = Promise.withResolvers() + let calls = 0 + render.mockImplementation(async ({ outputPath }) => { + if (++calls === 2) both.resolve(undefined) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const work = [renderer.render(distinct(1)), renderer.render(distinct(2)), renderer.render(distinct(3))] + try { + await both.promise + expect(kit.create).toHaveBeenCalledTimes(2) + expect(render).toHaveBeenCalledTimes(2) + } finally { release.resolve(undefined); await Promise.all(work) } + expect(render).toHaveBeenCalledTimes(3) + expect(kit.create).toHaveBeenCalledTimes(2) +}) + +it.each(['caller', 'provider'] as const)('joins late kit completion and scratch cleanup after %s cancellation', async (owner) => { + const renderer = await mount() + const entered = Promise.withResolvers<{ signal: AbortSignal; scratch: string }>() + const release = Promise.withResolvers() + render.mockImplementation(async ({ inputPath, outputPath }, signal) => { + entered.resolve({ signal: signal!, scratch: dirname(inputPath) }) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const caller = new AbortController() + const work = renderer.render(request, caller.signal) + const rejected = expect(work).rejects.toMatchObject(owner === 'caller' ? { name: 'AbortError' } : { code: 'unavailable' }) + const { signal, scratch } = await entered.promise + let settled = false + const observed = work.then(() => { settled = true }, () => { settled = true }) + let closing: Promise | undefined + try { + if (owner === 'caller') caller.abort() + else closing = ctx.fiber.dispose() + await vi.waitFor(() => { expect(signal.aborted).toBe(true) }) + await rejected + await observed + expect(settled).toBe(true) + await access(scratch) + } finally { release.resolve(undefined); await rejected; await observed; await closing; await ctx.fiber.dispose() } + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) + if (owner === 'provider') expect(dispose).toHaveBeenCalledOnce() +}) + +it('joins initialization during disposal and never starts a render after cancellation', async () => { + const renderer = await mount() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + kit.create.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise }) + const work = renderer.render(request) + const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' }) + await entered.promise + const closing = ctx.fiber.dispose() + release.resolve({ backend: 'wasm', render, dispose }) + await rejected + await closing + expect(render).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) +}) + +it('rejects caller cancellation before allocating any conversion resources', async () => { + const renderer = await mount() + const reason = new Error('caller cancelled') + await expect(renderer.render(request, AbortSignal.abort(reason))).rejects.toBe(reason) + expect(kit.create).not.toHaveBeenCalled() +}) + +it('rejects relative font directories during provider configuration', async () => { + expect(() => new LibreOfficeRenderer(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + .toThrow('fontDirectories must contain absolute paths') +}) + +it('rejects source capacity below one permitted input before allocating a converter', () => { + expect(() => new LibreOfficeRenderer(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + .toThrow('maxSourceBytes must be at least maxInputBytes') + expect(kit.create).not.toHaveBeenCalled() +}) + +it('joins every converter disposal before reporting an engine cleanup failure', async () => { + const renderer = await mount({ maxConcurrentConversions: 2 }) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const cleanupEntered = Promise.withResolvers() + const cleanupRelease = Promise.withResolvers() + let conversions = 0 + render.mockImplementation(async ({ outputPath }) => { + if (++conversions === 2) entered.resolve(undefined) + await released.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const failed = new Error('engine cleanup failed') + kit.create.mockReset() + .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } }) + .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) + const work = [renderer.render(distinct(1)), renderer.render(distinct(2))] + try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) } + let disposed = false + const closing = ctx.fiber.dispose().then(() => { disposed = true }) + try { + await cleanupEntered.promise + expect(disposed).toBe(false) + } finally { cleanupRelease.resolve(undefined); await closing } +}) diff --git a/packages/document/document-render-libreoffice/tests/queue.spec.ts b/packages/document/document-render-libreoffice/tests/queue.spec.ts new file mode 100644 index 0000000000..9ac47223d5 --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/queue.spec.ts @@ -0,0 +1,563 @@ +/** Controlled source and engine completions exercise admission and shared content ownership. */ +import { DocumentSourceKey, DocumentRendererGeneration, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { expect, it, onTestFinished, vi } from 'vitest' +import { RenderQueue } from '../src/queue.ts' +import { Config } from '../src/index.ts' + +const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } +function source(key: string, byte = 1, priority: DocumentRenderRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: DocumentRenderRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } + return { read, request } +} +function harness(config: Partial = {}) { + const convert = vi.fn[2]>().mockResolvedValue(output) + const queue = new RenderQueue(Config(config), DocumentRendererGeneration('test'), convert) + onTestFinished(() => queue.dispose()) + return { queue, convert } +} + +it('shares authorized source metadata before reading and content across distinct source paths', async () => { + const h = harness() + const entered = Promise.withResolvers() + const complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const a = source('a'), b = source('b') + const first = h.queue.read(a.request) + await entered.promise + const same = h.queue.read(a.request) + const equalContent = h.queue.read(b.request) + try { + await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() }) + expect(a.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output) } + const results = await Promise.all([first, same, equalContent]) + expect(new Set(results.map(result => result.cacheKey)).size).toBe(1) + results[0].pdf[0] = 0 + results[0].missingFonts.length = 0 + expect(results[1].pdf).toEqual(output.pdf) + expect((await h.queue.read(b.request)).missingFonts).toEqual(['Font']) + expect(b.read).toHaveBeenCalledOnce() +}) + +it('continues queued work when the first queued reader cancels immediately after admission', async () => { + const h = harness({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active', 1).request) + await entered.promise + const cancelled = new AbortController() + const second = source('cancelled', 2), third = source('later', 3) + second.read.mockImplementation(async () => { + cancelled.abort() + return { bytes: new Uint8Array([2]), version: 'v1' } + }) + const queued = h.queue.read(second.request, cancelled.signal) + const rejected = expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + const later = h.queue.read(third.request) + try { + expect(second.read).not.toHaveBeenCalled() + expect(third.read).not.toHaveBeenCalled() + } finally { complete.resolve(output) } + await first + await rejected + await expect(later).resolves.toMatchObject(output) + expect(second.read).toHaveBeenCalledOnce() + expect(third.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) +}) + +it('promotes a queued prewarm when a foreground reader joins, without another source read', async () => { + const h = harness({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active', 1).request) + await entered.promise + const order: string[] = [] + const a = source('a', 2, 'background'), b = source('b', 3, 'background') + a.read.mockImplementation(async () => { order.push('a'); return { bytes: new Uint8Array([2]), version: 'v1' } }) + b.read.mockImplementation(async () => { order.push('b'); return { bytes: new Uint8Array([3]), version: 'v1' } }) + const backgroundA = h.queue.read(a.request), backgroundB = h.queue.read(b.request) + const foreground = h.queue.read({ ...b.request, priority: 'foreground' }) + expect(a.read).not.toHaveBeenCalled() + expect(b.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, backgroundA, backgroundB, foreground]) + expect(order).toEqual(['b', 'a']) + expect(b.read).toHaveBeenCalledOnce() +}) + +it('evicts queued speculation for foreground work and bounds the metadata queue', async () => { + const h = harness({ maxConcurrentConversions: 1, maxQueuedJobs: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active').request) + await entered.promise + const prewarm = source('prewarm', 2, 'background') + const discarded = expect(h.queue.read(prewarm.request)).rejects.toMatchObject({ code: 'busy' }) + const overflow = source('background-overflow', 4, 'background') + await expect(h.queue.read(overflow.request)).rejects.toMatchObject({ code: 'busy' }) + expect(overflow.read).not.toHaveBeenCalled() + const requested = source('requested', 3) + const next = h.queue.read(requested.request) + await expect(h.queue.read(source('overflow', 4).request)).rejects.toMatchObject({ code: 'busy' }) + await discarded + expect(prewarm.read).not.toHaveBeenCalled() + expect(requested.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, next]) + expect(requested.read).toHaveBeenCalledOnce() +}) + +it('refuses disabled prewarming before reading a source while admitting foreground work', async () => { + const h = harness({ maxBackgroundConversions: 0 }) + const a = source('a', 1, 'background') + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + expect(a.read).not.toHaveBeenCalled() + expect(h.convert).not.toHaveBeenCalled() + await h.queue.read({ ...a.request, priority: 'foreground' }) + expect(a.read).toHaveBeenCalledOnce() +}) + +it('refuses disabled prewarming joins and releases cancelled foreground capacity', async () => { + const h = harness({ maxBackgroundConversions: 0, maxConcurrentConversions: 1, maxQueuedJobs: 1, maxReaders: 5 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const running = source('running', 1), queued = source('queued', 2), later = source('later', 3) + const foreground = new AbortController(), background = new AbortController() + const pending: Promise[] = [h.queue.read(running.request)] + const rejected = vi.fn() + try { + await entered.promise + pending.push(h.queue.read(queued.request, foreground.signal).catch((error: unknown) => error)) + for (const item of [running, queued]) { + pending.push(h.queue.read({ ...item.request, priority: 'background' }, background.signal).catch(rejected)) + } + await expect.poll(() => rejected.mock.calls.length).toBe(2) + expect(rejected.mock.calls).toEqual([[expect.objectContaining({ code: 'busy' })], [expect.objectContaining({ code: 'busy' })]]) + foreground.abort() + const next = h.queue.read(later.request) + pending.push(next) + expect(queued.read).not.toHaveBeenCalled() + expect(later.read).not.toHaveBeenCalled() + complete.resolve(output) + await expect(next).resolves.toMatchObject(output) + await expect(h.queue.read({ ...running.request, priority: 'background' })).resolves.toMatchObject(output) + expect(running.read).toHaveBeenCalledOnce() + expect(later.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { + foreground.abort() + background.abort() + complete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('reserves foreground capacity while limiting concurrent prewarming', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('first', 1, 'background').request) + await entered.promise + const second = source('second', 2, 'background') + const background = h.queue.read(second.request) + const foreground = source('foreground', 3) + await h.queue.read(foreground.request) + expect(second.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, background]) +}) + +it('holds reserved source capacity until canceled engine work actually settles', async () => { + const h = harness({ maxConcurrentConversions: 2, maxInputBytes: 1, maxSourceBytes: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const caller = new AbortController() + const firstSource = source('first'), nextSource = source('next', 2) + const first = expect(h.queue.read(firstSource.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + caller.abort() + await first + expect(signal.aborted).toBe(true) + const next = h.queue.read(nextSource.request) + expect(nextSource.read).not.toHaveBeenCalled() + complete.resolve(output) + await next + await h.queue.read(firstSource.request) + expect(firstSource.read).toHaveBeenCalledTimes(2) +}) + +it('bounds shared readers and leaves other readers alive after cancellation', async () => { + const h = harness({ maxReaders: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const a = source('a'), caller = new AbortController() + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + const second = h.queue.read(a.request) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + caller.abort() + await first + expect(signal.aborted).toBe(false) + complete.resolve(output) + await second +}) + +it('keeps digest-shared readers alive when the original source reader leaves', async () => { + const h = harness() + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const a = source('a'), b = source('b'), caller = new AbortController() + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + const second = h.queue.read(b.request) + await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() }) + caller.abort() + await first + expect(signal.aborted).toBe(false) + complete.resolve(output) + await second + expect(h.convert).toHaveBeenCalledOnce() +}) + +it('rejects source version changes and oversized reads without converting or retaining them', async () => { + const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 }) + const a = source('a') + a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1]), version: 'v2' }) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'source-changed' }) + a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1, 2]), version: 'v1' }) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'input-too-large' }) + expect(h.convert).not.toHaveBeenCalled() + await h.queue.read(a.request) + expect(a.read).toHaveBeenCalledTimes(3) + expect(a.read.mock.calls[0]![1]).toBe(1) +}) + +it('reserves the input cap for an unknown stat size and rejects known overflow before reading', async () => { + const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 }) + const a = source('a') + await expect(h.queue.read({ ...a.request, source: { ...a.request.source, bytes: 3 } })).rejects.toMatchObject({ code: 'input-too-large' }) + expect(a.read).not.toHaveBeenCalled() + const { bytes: _bytes, ...unknown } = a.request.source + await h.queue.read({ ...a.request, source: unknown }) + expect(a.read.mock.calls[0]![1]).toBe(2) +}) + +it('evicts least-recently-used content and bounds pre-read aliases independently', async () => { + const h = harness({ maxCachedEntries: 2, maxCachedBytes: 8, maxSourceEntries: 1 }) + const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3) + await h.queue.read(a.request) + await h.queue.read(alias.request) + await h.queue.read(a.request) + expect(a.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledOnce() + await h.queue.read(b.request) + await h.queue.read(a.request) + await h.queue.read(c.request) + await h.queue.read(b.request) + expect(h.convert).toHaveBeenCalledTimes(4) +}) + +it('does not retain failures or PDFs above the retention budget', async () => { + const h = harness({ maxCachedBytes: 1 }) + const a = source('a') + h.convert.mockRejectedValueOnce(new Error('engine failure')) + await expect(h.queue.read(a.request)).rejects.toThrow('engine failure') + await h.queue.read(a.request) + await h.queue.read(a.request) + expect(h.convert).toHaveBeenCalledTimes(3) +}) + +it('evicts every alias of the least-recently-used PDF while preserving other aliases', async () => { + const h = harness({ maxCachedEntries: 2 }) + const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3) + await h.queue.read(a.request) + await h.queue.read(alias.request) + await h.queue.read(b.request) + await h.queue.read(c.request) + await h.queue.read(b.request) + expect(b.read).toHaveBeenCalledOnce() + await h.queue.read(a.request) + await h.queue.read(alias.request) + expect(a.read).toHaveBeenCalledTimes(2) + expect(alias.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledTimes(4) +}) + +it('keeps a synchronous abort replacement shareable after the old conversion settles late', async () => { + const h = harness({ maxConcurrentConversions: 2 }) + const firstEntered = Promise.withResolvers(), firstComplete = Promise.withResolvers() + const replacementEntered = Promise.withResolvers(), replacementComplete = Promise.withResolvers() + const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() + const a = source('a'), caller = new AbortController() + const pending: Promise[] = [] + h.convert.mockImplementationOnce((_bytes, _extension, signal) => { + signal.addEventListener('abort', () => { + const work = h.queue.read(a.request) + pending.push(work) + replacementSpawned.resolve({ work }) + }, { once: true }) + firstEntered.resolve(undefined) + return firstComplete.promise + }) + .mockImplementationOnce(() => { replacementEntered.resolve(undefined); return replacementComplete.promise }) + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ cause: 'reader left' }) + pending.push(first) + try { + await firstEntered.promise + caller.abort('reader left') + await first + const { work: replacement } = await replacementSpawned.promise + await replacementEntered.promise + const next = h.queue.read(source('next', 2).request) + pending.push(next) + firstComplete.resolve(output) + await next + const sameSource = h.queue.read(a.request) + const alias = source('alias', 1, 'background') + const sameContent = h.queue.read(alias.request) + pending.push(sameSource, sameContent) + await vi.waitFor(() => { expect(alias.read).toHaveBeenCalledOnce() }) + expect(a.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledTimes(3) + replacementComplete.resolve(output) + const results = await Promise.all([replacement, sameSource, sameContent]) + expect(new Set(results.map(result => result.cacheKey)).size).toBe(1) + } finally { + firstComplete.resolve(output) + replacementComplete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('separates Office extensions and renderer generations in content identity', async () => { + const h = harness(), a = source('a') + const first = await h.queue.read(a.request) + const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) + const other = new RenderQueue(Config({}), DocumentRendererGeneration('replacement'), h.convert) + onTestFinished(() => other.dispose()) + const replacement = await other.read(a.request) + expect(first.cacheKey).not.toBe(otherFormat.cacheKey) + expect(first.cacheKey).not.toBe(replacement.cacheKey) +}) + +it('reserves the final reader admission for foreground interest', async () => { + const h = harness({ maxReaders: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const a = source('a', 1, 'background') + const background = h.queue.read(a.request) + await entered.promise + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + const foreground = h.queue.read({ ...a.request, priority: 'foreground' }) + complete.resolve(output) + await Promise.all([background, foreground]) + expect(a.read).toHaveBeenCalledOnce() +}) + +it('serves ready PDFs while every outstanding reader slot is occupied', async () => { + const h = harness({ maxReaders: 1 }), cached = source('cached') + await h.queue.read(cached.request) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const pending = h.queue.read(source('active', 2).request) + try { + await entered.promise + await expect(h.queue.read(cached.request)).resolves.toMatchObject(output) + expect(cached.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { complete.resolve(output); await pending } +}) + +it('keeps background reservations behind foreground work waiting for source capacity', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), foregroundEntered = Promise.withResolvers() + const complete = Promise.withResolvers(), foregroundComplete = Promise.withResolvers() + const order: number[] = [] + h.convert.mockImplementation((bytes) => { + order.push(bytes[0]!) + if (bytes[0] === 1) { entered.resolve(undefined); return complete.promise } + if (bytes[0] === 2) { foregroundEntered.resolve(undefined); return foregroundComplete.promise } + return Promise.resolve(output) + }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + await entered.promise + const next = h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } }) + const speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } }) + try { + expect(foreground.read).not.toHaveBeenCalled() + expect(background.read).not.toHaveBeenCalled() + complete.resolve(output) + await foregroundEntered.promise + expect(order).toEqual([1, 2]) + } finally { + complete.resolve(output) + foregroundComplete.resolve(output) + await Promise.allSettled([first, next, speculative]) + } + expect(order).toEqual([1, 2, 3]) +}) + +it('starts eligible background work when its queued foreground blocker is cancelled', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + const caller = new AbortController() + let speculative: Promise | undefined + try { + await entered.promise + const cancelled = expect(h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' }) + speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } }) + expect(background.read).not.toHaveBeenCalled() + caller.abort() + await cancelled + expect(foreground.read).not.toHaveBeenCalled() + expect(background.read).toHaveBeenCalledOnce() + await speculative + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { caller.abort(); complete.resolve(output); await Promise.allSettled([first, speculative]) } +}) + +it('starts eligible background work after a queued promotion loses its final foreground reader', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), promoted = source('promoted', 2, 'background'), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + const callers = [new AbortController(), new AbortController()] + const pending: Promise[] = [first] + try { + await entered.promise + const request = { ...promoted.request, source: { ...promoted.request.source, bytes: 48 } } + pending.push(h.queue.read(request)) + const cancelled = callers.map(caller => expect(h.queue.read({ ...request, priority: 'foreground' }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' })) + pending.push(...cancelled) + pending.push(h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } })) + expect(background.read).not.toHaveBeenCalled() + callers[0]!.abort() + await cancelled[0] + expect(background.read).not.toHaveBeenCalled() + callers[1]!.abort() + await cancelled[1] + expect(promoted.read).not.toHaveBeenCalled() + expect(background.read).toHaveBeenCalledOnce() + } finally { + for (const caller of callers) caller.abort() + complete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('counts a demoted queued prewarm against background concurrency when it starts', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const complete = Promise.withResolvers(), prewarmComplete = Promise.withResolvers() + const prewarmEntered = Promise.withResolvers() + h.convert.mockImplementation((bytes) => { + if (bytes[0] === 1 || bytes[0] === 2) return complete.promise + if (bytes[0] === 3) { prewarmEntered.resolve(undefined); return prewarmComplete.promise } + return Promise.resolve(output) + }) + const first = h.queue.read(source('first', 1).request), second = h.queue.read(source('second', 2).request) + const prewarm = source('promoted', 3, 'background'), other = source('other', 4, 'background') + const warming = h.queue.read(prewarm.request), waiting = h.queue.read(other.request) + const caller = new AbortController() + const cancelled = expect(h.queue.read({ ...prewarm.request, priority: 'foreground' }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' }) + try { + expect(prewarm.read).not.toHaveBeenCalled() + caller.abort() + await cancelled + complete.resolve(output) + await prewarmEntered.promise + await h.queue.read(source('foreground', 5).request) + expect(other.read).not.toHaveBeenCalled() + } finally { + caller.abort() + complete.resolve(output) + prewarmComplete.resolve(output) + await Promise.allSettled([first, second, warming, waiting, cancelled]) + } +}) + +it('releases source indexes when the last reader for each digest-shared path leaves', async () => { + const h = harness({ maxReaders: 2, maxSourceEntries: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('retained').request) + await entered.promise + // Both retained indexes must stay bounded while the underlying converter is blocked. + const indexes = h.queue as unknown as { sources: Map }> } + const retained = [...indexes.sources.values()][0]! + try { + for (let index = 0; index < 5; index++) { + const alias = source(`cancelled-${index}`), caller = new AbortController() + const joined = h.queue.read(alias.request, caller.signal) + const rejected = expect(joined).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => { expect(retained.sources.size).toBe(2) }) + caller.abort() + await rejected + expect(indexes.sources.size).toBe(1) + expect(retained.sources.size).toBe(1) + } + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output); await first } +}) + +it('rereads a cancelled source on reopen while sharing its running or completed conversion', async () => { + const h = harness({ maxReaders: 2, maxSourceEntries: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('retained').request), reopened = source('reopened') + try { + await entered.promise + for (const count of [1, 2]) { + const caller = new AbortController() + const cancelled = expect(h.queue.read(reopened.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + try { await vi.waitFor(() => { expect(reopened.read).toHaveBeenCalledTimes(count) }) } + finally { caller.abort(); await cancelled } + } + complete.resolve(output) + await first + await h.queue.read(reopened.request) + expect(reopened.read).toHaveBeenCalledTimes(3) + await h.queue.read(reopened.request) + expect(reopened.read).toHaveBeenCalledTimes(3) + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output); await first } +}) + +it('keeps speculative admission occupied after promotion while allowing other foreground work', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const original = source('original', 1, 'background'), waiting = source('waiting', 2, 'background') + const first = h.queue.read(original.request) + await entered.promise + const promoted = h.queue.read({ ...original.request, priority: 'foreground' }) + const speculative = h.queue.read(waiting.request) + try { + await h.queue.read(source('requested', 3).request) + expect(waiting.read).not.toHaveBeenCalled() + } finally { complete.resolve(output); await Promise.all([first, promoted, speculative]) } + expect(waiting.read).toHaveBeenCalledOnce() +}) + +it('classifies provider disposal independently from caller cancellation', async () => { + const h = harness() + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const pending = expect(h.queue.read(source('active').request)).rejects.toMatchObject({ code: 'unavailable' }) + await entered.promise + const closing = h.queue.dispose() + try { + await pending + await expect(h.queue.read(source('later').request)).rejects.toMatchObject({ code: 'unavailable' }) + } finally { complete.resolve(output); await closing } +}) diff --git a/packages/document/document-render-libreoffice/tsconfig.json b/packages/document/document-render-libreoffice/tsconfig.json new file mode 100644 index 0000000000..8e81caaa59 --- /dev/null +++ b/packages/document/document-render-libreoffice/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../document-render" + } + ] +} diff --git a/packages/document/document-render/README.i18n.yaml b/packages/document/document-render/README.i18n.yaml new file mode 100644 index 0000000000..1fff641553 --- /dev/null +++ b/packages/document/document-render/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/document/document-render/README.md +README.md: ead6c62b25dcca326ad362af4e28a6431472f55c +README.zh.md: 45138e306a367833e0acade97b7d13f46fdcefa8 diff --git a/packages/document/document-render/README.md b/packages/document/document-render/README.md new file mode 100644 index 0000000000..ead6c62b25 --- /dev/null +++ b/packages/document/document-render/README.md @@ -0,0 +1,75 @@ +--- +description: "Authorized Office input and complete PDF output for Host conversion providers." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render + +English | [中文](README.zh.md) + +## Summary + +Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes to a complete PDF. Callers receive PDF bytes and, for OOXML inputs, missing-font families without changing the original file or adding model context. + +## 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) + +----- + + +## Use this package + +Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to supply `ctx.documentRender`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `render()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentRenderError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentRenderError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. + +
+ +----- + + +## Further Exploration + +- [Document rendering](../../../docs/subsystems/document-render.md) — composition and ownership. +- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. + +----- + + +## 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 + + + +- The service accepts only the six listed Office formats; source authorization belongs to consumers, while shared conversion reuse belongs to the provider. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/document-render/README.zh.md b/packages/document/document-render/README.zh.md new file mode 100644 index 0000000000..45138e306a --- /dev/null +++ b/packages/document/document-render/README.zh.md @@ -0,0 +1,75 @@ +--- +description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF 输出。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render + +[English](README.md) | 中文 + +## 概述 + +将已授权的 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 字节转换为完整 PDF。调用方获得 PDF 字节;OOXML 输入还提供缺失字体名称,不修改原文件或添加模型上下文。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +挂载 [LibreOffice 提供方](../document-render-libreoffice/README.zh.md)以提供 `ctx.documentRender`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `render()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 + +----- + + +## 理解实现 + +
+实现内部细节 — 点击展开 + +提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和渲染 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentRenderError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentRenderError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 + +
+ +----- + + +## 延伸阅读 + +- [文档渲染](../../../docs/subsystems/document-render.zh.md) — 组合和所有权。 +- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 + +----- + + +## 模型体验 + +无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 + +#### KV Cache effect + +无;转换不会构造或修改模型请求。 + +## 已知限制与延后工作 + + + +- 服务仅接受上述六种 Office 格式;源文件授权由消费者负责,共享转换复用由提供方负责。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render/package.json b/packages/document/document-render/package.json new file mode 100644 index 0000000000..741ffca7db --- /dev/null +++ b/packages/document/document-render/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-document-render", + "description": "Authorized Office bytes to PDF rendering service", + "version": "0.1.6-alpha.1", + "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/document-render" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^" + } +} diff --git a/packages/document/document-render/src/index.ts b/packages/document/document-render/src/index.ts new file mode 100644 index 0000000000..49d121945e --- /dev/null +++ b/packages/document/document-render/src/index.ts @@ -0,0 +1,44 @@ +/** Host document rendering: authorized Office bytes become a complete PDF. */ +import { Context, Service } from '@deepseek-ai/cordis' +import type { DocumentRenderErrorCode, DocumentRenderRequest, DocumentRenderResult, DocumentRendererGeneration } from './types.ts' + +export * from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class DocumentRenderError 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: DocumentRenderErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DocumentRenderError' + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** One Host renderer for already-authorized Office bytes. */ + documentRender: DocumentRenderer + } +} + +/** Load one provider subclass per context; consumers own source authorization. */ +export abstract class DocumentRenderer extends Service { + constructor(ctx: Context) { super(ctx, 'documentRender') } + + /** Changes whenever engine, font, or rendering configuration is replaced. */ + abstract readonly generation: DocumentRendererGeneration + + /** + * Convert Office bytes without modifying the source or writing Session events. + * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ + abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +} + +export default DocumentRenderer diff --git a/packages/document/document-render/src/types.ts b/packages/document/document-render/src/types.ts new file mode 100644 index 0000000000..ce23ae3cb6 --- /dev/null +++ b/packages/document/document-render/src/types.ts @@ -0,0 +1,71 @@ +/** Authorized Office input and complete PDF output, independent of the conversion engine. */ +import { brandString, type Branded } from '@deepseek-ai/dsh-brand' + +/** Binary Office and Office Open XML formats supported by document preview. */ +export type DocumentExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' + +/** Authorized execution scope and canonical source path, encoded by the consumer. */ +export type DocumentSourceKey = Branded<'DocumentSourceKey'> +/** + * Label an authorized source locator for pre-read deduplication. + * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. + * @returns branded source locator; source authorization remains the caller's responsibility. + */ +export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } + +/** One provider lifetime, including its engine, rendering settings, and font configuration. */ +export type DocumentRendererGeneration = Branded<'DocumentRendererGeneration'> +/** + * Label a provider lifetime. + * @param value - unique generation created by the provider. + * @returns branded renderer generation. + */ +export function DocumentRendererGeneration(value: string): DocumentRendererGeneration { + return brandString(value) +} + +/** Provider generation and source-content digest; consumers must not parse it. */ +export type DocumentRenderKey = Branded<'DocumentRenderKey'> +/** + * Label a renderer-owned content identity. + * @param value - generation and content identity created by the provider. + * @returns branded conversion identity. + */ +export function DocumentRenderKey(value: string): DocumentRenderKey { return brandString(value) } + +/** Foreground previews and explicit QA precede speculative background conversion. */ +export type DocumentRenderPriority = 'foreground' | 'background' + +/** Source authorization and metadata lookup must finish before submitting a request. */ +export interface DocumentRenderRequest { + readonly extension: DocumentExtension + readonly priority: DocumentRenderPriority + readonly source: { + readonly key: DocumentSourceKey + readonly version: string + /** Authorized stat size; omission reserves the provider's entire input limit. */ + readonly bytes?: number + /** + * Read only after provider admission; do not capture already-buffered input in queued production requests. + * @param signal - shared conversion lifetime, independent of an individual reader. + * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. + * @returns owned bytes and the actual read version; a changed version rejects conversion. + */ + read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> + } +} + +/** Successful conversion; failed and interrupted conversions reject instead. */ +export interface DocumentRenderResult { + /** Caller-owned complete PDF, valid after provider disposal. */ + readonly pdf: Uint8Array + /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ + readonly missingFonts: string[] + readonly cacheKey: DocumentRenderKey + readonly generation: DocumentRendererGeneration +} + +/** Failures a conversion consumer can present without exposing engine diagnostics. */ +export type DocumentRenderErrorCode = + | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' + | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-render/tsconfig.json b/packages/document/document-render/tsconfig.json new file mode 100644 index 0000000000..85055ef176 --- /dev/null +++ b/packages/document/document-render/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 1b815a3e4a..1e6f283ded 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 724e47a004fac83fe0f19a588faf0c227d843232 -README.zh.md: 4e88b3cf10f98a423268c60b69269d2550d2c1f9 +README.md: 6a6911824c8c71b3deb5037d1ecf629771211a26 +README.zh.md: c0ef4275507725bc6f1178ab2568c4fb6127479a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 724e47a004..6a6911824c 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -55,6 +55,7 @@ None; this package neither assembles nor sends a provider request. - **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here. - **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm or install native dependencies. - **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child. +- **Office conversion requires a Node Host**: the LibreOffice kit replacement rejects converter creation with `unavailable`. The browser image excludes the kit and its engine dependencies; the Office preview reports that conversion is unavailable. - **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop. - **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-system/landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation. - **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 4e88b3cf10..c0ef427550 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -55,6 +55,7 @@ kind: "package-library" - **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。 - **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm 或安装原生依赖。 - **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。 +- **Office 转换需要 Node Host**:LibreOffice kit 的替代模块以 `unavailable` 拒绝创建转换器。浏览器镜像排除 kit 及其引擎依赖;Office 预览提示转换不可用。 - **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。 - **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-system/landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。 - **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。 diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 05e9b74339..67ebb786cf 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record = { 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', // External npm replacements, named after the package each stands in for. + '@deepseek-ai/libreoffice-kit': './node/external_packages/libreoffice-kit.ts', '@deepseek-ai/node-addon-system/flock': './node/external_packages/node-addon-system-flock.ts', 'koffi': './node/external_packages/koffi.ts', 'sharp': './node/external_packages/sharp.ts', diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 649129e300..aa21754b4e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -47,6 +47,7 @@ import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' import * as systemFlock from './external_packages/node-addon-system-flock.ts' import * as koffi from './external_packages/koffi.ts' +import * as libreofficeKit from './external_packages/libreoffice-kit.ts' import * as nodePty from './external_packages/node-pty.ts' import * as execa from './external_packages/execa.ts' import * as piAi from './external_packages/pi-ai.ts' @@ -87,6 +88,7 @@ const BUILTINS: Record = { /** Exact package or subpath specifiers served by worker stubs and fakes. */ const EXTERNALS: Record = { + '@deepseek-ai/libreoffice-kit': () => libreofficeKit, '@deepseek-ai/node-addon-system/flock': () => systemFlock, 'koffi': () => koffi, 'sharp': () => sharp, diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts new file mode 100644 index 0000000000..a871627f38 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -0,0 +1,12 @@ +/** Office conversion requires a Node Host; the browser preview carries no engine assets. */ +import { notAvailableError } from '../notImplementedFail.ts' + +/** + * Refuse converter creation with the kit's unavailable error code. + * @returns A rejected promise consumed by the Host document-render provider. + */ +export function createConverter(): Promise { + return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { + code: 'unavailable', + })) +} diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 106aab2c6f..81f78e6506 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -9,6 +9,7 @@ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ '@earendil-works/pi-ai', '@vscode/ripgrep', + '@deepseek-ai/libreoffice-kit', '@deepseek-ai/node-addon-system/flock', 'koffi', 'node-pty', diff --git a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts index 24d84c79a9..e6101f17b3 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -21,6 +21,7 @@ import * as stream from '../../src/node/builtin_modules/implemented/stream.ts' import * as vm from '../../src/node/builtin_modules/mock/vm.ts' import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts' import * as nodePty from '../../src/node/external_packages/node-pty.ts' +import * as libreofficeKit from '../../src/node/external_packages/libreoffice-kit.ts' import * as execa from '../../src/node/external_packages/execa.ts' import * as piAi from '../../src/node/external_packages/pi-ai.ts' import * as ripgrep from '../../src/node/external_packages/ripgrep.ts' @@ -118,6 +119,14 @@ describe('constructible-but-inert fakes', () => { }) describe('replaced external packages', () => { + it('reports Office conversion as unavailable without creating a Node worker', async () => { + quiet() + await expect(libreofficeKit.createConverter()).rejects.toMatchObject({ + code: 'unavailable', + message: 'web-preview: @deepseek-ai/libreoffice-kit.createConverter is not available in the worker host', + }) + }) + it('lists the packages the loader serves from the bundle', () => { expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar') expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-system') diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8611059309..13943f8831 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -874,6 +874,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'documentRender', + summary: 'Load one provider subclass per context; consumers own source authorization.', + description: 'Load one provider subclass per context; consumers own source authorization.', + methods: [ + { + signature: 'abstract readonly generation: DocumentRendererGeneration', + description: 'Changes whenever engine, font, or rendering configuration is replaced.', + parameters: [], + }, + { + signature: 'abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise', + description: 'Convert Office bytes without modifying the source or writing Session events.', + parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], + returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', + throws: ['{DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], + }, + ], + }, { key: 'fileReferences', summary: 'Host capability for cancellable file-reference discovery.', @@ -3196,6 +3215,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', }, + { + signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', + description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.', + parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.', + }, { signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', description: 'Read a complete file relative to another file\'s directory, including outside the workspace.', @@ -4417,6 +4442,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, + { + name: 'DocumentExtension', + declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', + }, + { + name: 'DocumentRendererGeneration', + declaration: 'export type DocumentRendererGeneration = Branded<\'DocumentRendererGeneration\'>;', + }, + { + name: 'DocumentRenderKey', + declaration: 'export type DocumentRenderKey = Branded<\'DocumentRenderKey\'>;', + }, + { + name: 'DocumentRenderPriority', + declaration: 'export type DocumentRenderPriority = \'foreground\' | \'background\';', + }, + { + name: 'DocumentRenderRequest', + declaration: 'export interface DocumentRenderRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentRenderPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'DocumentRenderResult', + declaration: 'export interface DocumentRenderResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentRenderKey;\n readonly generation: DocumentRendererGeneration;\n}', + }, + { + name: 'DocumentSourceKey', + declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 015fc0c1a2..839bfa43b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2199,6 +2199,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner + '@deepseek-ai/dsh-document-render': + specifier: workspace:^ + version: link:../../document/document-render + '@deepseek-ai/dsh-document-render-libreoffice': + specifier: workspace:^ + version: link:../../document/document-render-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -5799,6 +5805,38 @@ importers: specifier: workspace:^ version: link:../../util/launch-environment + packages/document/document-render: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + + packages/document/document-render-libreoffice: + dependencies: + '@deepseek-ai/libreoffice-kit': + specifier: 0.0.1 + version: 0.0.1 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-document-render': + specifier: workspace:^ + version: link:../document-render + packages/experimental/agent-team: dependencies: '@deepseek-ai/dsh-brand': @@ -13124,6 +13162,39 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1': + resolution: {integrity: sha512-3vrYUIZihcb+F9qjjHdWNSxW83fLihrBV63qaw5kxW1Q7hbZPPMDsjlRJ2rX6VUi06By4nJH9YLKZ73iGN5iEQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [darwin] + + '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1': + resolution: {integrity: sha512-xMMXJkjggd9c1g85oLNHiJl1xgowe0EG60ib8up3Pz0qFD9HORMu0/eP1eB19E8jLRwqF9Sb0TTrcqp68aVcyw==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [darwin] + + '@deepseek-ai/libreoffice-kit-wasm@0.0.1': + resolution: {integrity: sha512-VHGr+MvRQMCXxVvdYZ8qJnVXOMjUivntvNHCDBog+cCjgEfWjUl+/0rfvF2auPjnWUzqvjKMEdqbemhc1zuqFg==} + engines: {node: '>=22.19.0'} + os: [linux] + + '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': + resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [win32] + + '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': + resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [win32] + + '@deepseek-ai/libreoffice-kit@0.0.1': + resolution: {integrity: sha512-e4JZqohz5TEVfI3sfUK/dHRLjqlQep96CmqNM93Rm7LY9PuECiJakIX2BdHi5OkmbK9Mo47SiW44HNTTiwW1yw==} + engines: {node: '>=22.19.0'} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -15200,6 +15271,9 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -16079,6 +16153,9 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.6: resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -16220,6 +16297,10 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -16584,6 +16665,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -16927,6 +17011,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -16968,6 +17055,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -18226,6 +18316,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -18553,6 +18646,9 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -18928,6 +19024,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -19113,6 +19212,12 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -20279,6 +20384,33 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-wasm@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit@0.0.1': + dependencies: + fflate: 0.8.2 + fontkit: 2.0.4 + saxes: 6.0.0 + optionalDependencies: + '@deepseek-ai/libreoffice-kit-darwin-arm64': 0.0.1 + '@deepseek-ai/libreoffice-kit-darwin-x64': 0.0.1 + '@deepseek-ai/libreoffice-kit-wasm': 0.0.1 + '@deepseek-ai/libreoffice-kit-win32-arm64': 0.0.1 + '@deepseek-ai/libreoffice-kit-win32-x64': 0.0.1 + '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': @@ -21898,6 +22030,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -22893,6 +23029,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.6: dependencies: baseline-browser-mapping: 2.10.43 @@ -23038,6 +23178,8 @@ snapshots: dependencies: mimic-response: 1.0.1 + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -23398,6 +23540,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + diff@8.0.4: {} diff@9.0.0: {} @@ -23895,6 +24039,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} + fflate@0.8.3: {} figures@6.1.0: @@ -23938,6 +24084,18 @@ snapshots: follow-redirects@1.16.0: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -25406,6 +25564,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + pako@1.0.11: {} parse-ms@4.0.0: {} @@ -25724,6 +25884,8 @@ snapshots: dependencies: lowercase-keys: 2.0.0 + restructure@3.0.2: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -26246,6 +26408,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-inflate@1.0.3: {} + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -26389,6 +26553,16 @@ snapshots: undici@8.10.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} union@0.5.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0f9020b1b2..05802dbbd7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,13 @@ allowBuilds: msgpackr-extract: false minimumReleaseAgeExclude: + # Office engines and their API are qualified and released together by the kit repository. + - '@deepseek-ai/libreoffice-kit@0.0.1' + - '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1' + - '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1' + - '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1' + - '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1' + - '@deepseek-ai/libreoffice-kit-wasm@0.0.1' # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.85.1' diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index dd883770cc..631a6680d5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 8ab226427f89d2746d6da13bcd8bd254731f2460 -README.zh.md: 23a042006c38425a83347322fa63f0fafb319d0d +README.md: ba1814ce00dea9dd35bcfbb330801942b86af155 +README.zh.md: 792c7e06a29be987a25887882b6df976138b194f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 8ab226427f..ba1814ce00 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -10,6 +10,8 @@ The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Py Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. +Each target also requires `-office/`, where the stem excludes `.exe`. This directory contains the complete installed Office packages and their dependencies, preserving engine resources, manifests, licenses, source inventories, and helper permissions. Copy this directory together with the executable. A missing target engine fails the sidecar build with its npm package name and target platform/architecture. + Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. @@ -27,6 +29,8 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr `dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. +The Python bootstrap resolves the Office kit from its adjacent directory so native helpers and URL Workers use real filesystem paths. The kit owns engine selection and validation; the Python bootstrap adds no runtime download or compilation. + External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. ## Build and distribution @@ -35,4 +39,4 @@ Production deployment permits unused workspace patches for packages outside the From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. -The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: native on macOS/Windows and WASM on Linux. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 23a042006c..792c7e06a2 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -10,6 +10,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。wheel 包标签必须与载荷严格匹配;不发布 Windows arm64 wheel 包。 +每个目标还要求 `-office/`,其中 stem 不含 `.exe`。该目录包含完整的已安装 Office 包及其依赖,保留引擎资源、清单、许可证、源码清单与辅助程序权限。复制可执行文件时必须一并复制此目录。缺少目标引擎会使 sidecar 构建失败,错误会指出其 npm 包名与目标平台/架构。 + 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。 两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest(元数据清单)定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 @@ -27,6 +29,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python `dsh` 在显式指定的主目录下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理复现运行时的显式导出项、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 +Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。kit 负责引擎选择与校验;Python bootstrap 不增加运行时下载或编译。 + 外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 ## 构建与分发 @@ -35,4 +39,4 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 -已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 +已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:macOS/Windows 使用原生引擎,Linux 使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 22d0457d86..d642eec5e2 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -73,6 +73,8 @@ class RuntimeBuildHook(BuildHookInterface): f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) expected_executable = matches[0][1] + target = next(name for name, value in _PLATFORMS.items() if value[0] == platform_tag) + engine = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] @@ -84,6 +86,8 @@ class RuntimeBuildHook(BuildHookInterface): ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") + office = runtime_dir / f"{expected_executable.removesuffix('.exe')}-office" + expected_files.append(office.name) expected_files.sort() found_files = [path.name for path in runtime_files] if found_files != expected_files: @@ -91,6 +95,13 @@ class RuntimeBuildHook(BuildHookInterface): f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) for executable in runtime_files: + if executable == office: + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + if not (office / "node_modules" / required).is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {office / required}") + continue + if not executable.is_file(): + raise RuntimeError(f"runtime executable is not a file: {executable}") if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 4005314aa0..4e1c58fd2d 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -22,7 +22,7 @@ dsh = "deepseek_harness_runtime:main" # Include the injected dsh executable and sidecars; exclude the dev-only node closure. [tool.hatch.build] -artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*"] +artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*", "src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**"] exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] diff --git a/python/sdk-runtime/runtime-bootstrap.mjs b/python/sdk-runtime/runtime-bootstrap.mjs index 6ca1c1da82..3db857c0a7 100644 --- a/python/sdk-runtime/runtime-bootstrap.mjs +++ b/python/sdk-runtime/runtime-bootstrap.mjs @@ -1,6 +1,19 @@ #!/usr/bin/env node /** Private entry owned by the Python single-file runtime packaging. */ -import { fileURLToPath } from 'node:url' +import { registerHooks } from 'node:module' +import { isSea } from 'node:sea' +import { fileURLToPath, pathToFileURL } from 'node:url' + +if (isSea()) { + // Office spawns executable helpers and URL workers; its complete package tree must be real files. + const parentURL = pathToFileURL(`${process.execPath.replace(/\.exe$/i, '')}-office/package.json`).href + registerHooks({ + resolve(specifier, context, nextResolve) { + const office = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json' + return nextResolve(specifier, office ? { ...context, parentURL } : context) + }, + }) +} const selectorName = 'DSH_SUBPROCESS_RUNNER' const selection = process.env[selectorName] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 5235ff4d00..546bab36c7 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -57,7 +57,7 @@ def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. Raises FileNotFoundError when the platform is unsupported, the executable - has not been placed into this package, the required ripgrep sidecar is + has not been placed into this package, the ripgrep or Office sidecar is missing, or the required macOS spawn helper is missing; the message names the acquisition routes (acquisition strategy is deliberately separate from this lookup interface, so an on-demand download can replace it without @@ -88,6 +88,14 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + _EXE_ACQUISITION_HINT ) + office = path.with_name(f"{path.name.removesuffix('.exe')}-office") + engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + if not (office / "node_modules" / required).is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 9c87c5fe82..b3a9e259ef 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import runpy +import zipfile from pathlib import Path from types import SimpleNamespace @@ -119,6 +120,10 @@ def test_stage_runtime_copies_platform_payload( spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o755) expected[spawn_helper.name] = b"helper" + office = executable.parent / build_python_release.office_sidecar_name(executable.name) + office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data" + office_asset.parent.mkdir(parents=True) + office_asset.write_bytes(b"office data") destination = tmp_path / "staging" build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) @@ -127,7 +132,9 @@ def test_stage_runtime_copies_platform_payload( assert { path.name: path.read_bytes() for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*") + if path.is_file() } == expected + assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data" pyproject = (destination / "pyproject.toml").read_text() assert 'license = "MIT"' in pyproject assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject @@ -152,3 +159,42 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> executable, "deepseek-harness-sdk-runtime-win-x64.exe", ) + + +@pytest.mark.parametrize("platform_tag,selected", [ + ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"), + ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"), +]) +@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"]) +def test_office_wheel_requires_only_target_engine( + tmp_path: Path, platform_tag: str, selected: str, invalid: str | None, +) -> None: + root = "runtime-office/node_modules" + wheel = tmp_path / "office.zip" + native = selected != "wasm" + if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"): + pytest.skip("executable mode applies to POSIX native helpers") + engine = ({"kind": "native", "executable": "bin/helper"} if native else + {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"}) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", "{}") + base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}" + if invalid != "missing-engine": + archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine})) + for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")): + if invalid == "asset" and field == ("executable" if native else "data"): + continue + asset = zipfile.ZipInfo(f"{base}/{engine[field]}") + asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16 + archive.writestr(asset, b"payload") + if invalid == "foreign-engine": + foreign = "wasm" if native else "darwin-arm64" + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}") + with zipfile.ZipFile(wheel) as archive: + if invalid is None: + build_python_release.verify_office_payload(archive, root, platform_tag) + else: + message = {"asset": "asset is missing", "missing-engine": "dependency is missing", + "foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid] + with pytest.raises(RuntimeError, match=message): + build_python_release.verify_office_payload(archive, root, platform_tag) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index dd520df78b..0c6fa828a2 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -19,6 +19,17 @@ from deepseek_harness_runtime import ( ) +def _office_sidecar(executable: Path) -> Path: + office = executable.with_name(f"{executable.name.removesuffix('.exe')}-office") + tag = executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") + engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + path = office / "node_modules" / required + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}") + return office + + def test_unknown_explicit_mode_fails_loud() -> None: with pytest.raises(ValueError, match="expected 'exe' or 'node'"): resolve_bundled_launch_args("bogus") @@ -47,6 +58,7 @@ def test_runtime_requires_spawn_helper_only_on_macos( linux = runtime_dir / "deepseek-harness-sdk-runtime-linux-x64" linux.touch() Path(f"{linux}-rg").touch() + _office_sidecar(linux) macos = runtime_dir / "deepseek-harness-sdk-runtime-macos-arm64" macos.touch() Path(f"{macos}-rg").touch() @@ -67,6 +79,7 @@ def test_windows_runtime_uses_exe_payload_and_exe_sidecar( executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe" executable.touch() (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch() + _office_sidecar(executable) monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64") @@ -103,6 +116,25 @@ def test_runtime_requires_ripgrep_sidecar( runtime.bundled_runtime_path() +def test_runtime_requires_complete_office_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + executable = tmp_path / "runtime" / "deepseek-harness-sdk-runtime-linux-x64" + executable.parent.mkdir() + executable.touch() + Path(f"{executable}-rg").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() + office = _office_sidecar(executable) + assert runtime.bundled_runtime_path() == executable + (office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink() + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() + + def test_node_mode_runs_the_deployed_dsh_cli( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -195,3 +227,25 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: assert result.stdout == "stdout-中文\n" assert result.stderr == "stderr-中文\n" assert sentinel.read_text() == "done" + + +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "win-x64"]) +def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None: + extension = ".exe" if target.startswith("win-") else "" + executable = tmp_path / "runtime" / f"deepseek-harness-sdk-runtime-{target}{extension}" + executable.parent.mkdir() + executable.touch() + executable.with_name(f"{executable.stem}-rg{extension}").touch() + if target.startswith("macos-"): + Path(f"{executable}-spawn-helper").touch() + office = _office_sidecar(executable) + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: target) + assert runtime.bundled_runtime_path() == executable + engine = next(office.glob("node_modules/@deepseek-ai/libreoffice-kit-*/prebuilds.json")) + engine.unlink() + foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if target.startswith("linux-") else "libreoffice-kit-wasm") / "prebuilds.json" + foreign.parent.mkdir(parents=True, exist_ok=True) + foreign.write_text("{}") + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() diff --git a/scripts/build-exe-for-python-sdk-office.spec.ts b/scripts/build-exe-for-python-sdk-office.spec.ts new file mode 100644 index 0000000000..de7ae69b5b --- /dev/null +++ b/scripts/build-exe-for-python-sdk-office.spec.ts @@ -0,0 +1,137 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-python-office-')) + temporaryDirectories.push(root) + const staging = join(root, 'staging') + const destination = join(root, 'runtime-office') + async function packageAt(name: string, fields: Record = {}, parent = staging) { + const directory = join(parent, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...fields })) + return directory + } + return { root, staging, destination, packageAt } +} + +it('copies package-owned data, licenses, helper permissions, and nested dependencies', async () => { + const { staging, destination, packageAt } = await fixture() + const entry = await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' }, dependencies: { decoder: '1' } }) + const engine = await packageAt('@deepseek-ai/libreoffice-kit-wasm') + const decoder = await packageAt('decoder', { dependencies: { codec: '1' } }, entry) + await packageAt('codec', {}, decoder) + await packageAt('codec', { version: '2.0.0' }) + const assets = ['assets/soffice.data', 'prebuilds.json', 'licenses/LICENSE', 'bin/helper'] + for (const name of assets) { + const path = join(engine, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, name) + } + await chmod(join(engine, 'bin/helper'), 0o755) + + const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) + + expect(packages).toHaveLength(4) + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data'), 'utf8')).toBe('assets/soffice.data') + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/licenses/LICENSE'), 'utf8')).toBe('licenses/LICENSE') + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/node_modules/decoder/node_modules/codec/package.json'), 'utf8')).toContain('codec') + await expect(stat(join(destination, 'node_modules/codec'))).rejects.toMatchObject({ code: 'ENOENT' }) + if (process.platform !== 'win32') expect((await stat(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/bin/helper'))).mode & 0o111).toBe(0o111) +}) + +it('copies installed target optionals and leaves other platforms and absent optionals out', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { + optionalDependencies: { native: '1', foreign: '1', absent: '1' }, + }) + await packageAt('@deepseek-ai/libreoffice-kit-wasm') + await packageAt('native', { os: ['linux'], cpu: ['x64'] }) + await packageAt('foreign', { os: ['darwin'], cpu: ['arm64'] }) + + const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) + + expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + 'node_modules/@deepseek-ai/libreoffice-kit', + 'node_modules/@deepseek-ai/libreoffice-kit-wasm', + 'node_modules/native', + ]) +}) + +it('rejects a missing required dependency before producing a sidecar', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'dsh-missing-office-fixture': '1' } }) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('dsh-missing-office-fixture required by @deepseek-ai/libreoffice-kit is missing') + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects an incomplete installed optional instead of omitting it', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { broken: '1' } }) + await mkdir(join(staging, 'node_modules/broken'), { recursive: true }) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects an ancestor dependency outside the deployed closure', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'ancestor-office-fixture': '1' } }) + await packageAt('ancestor-office-fixture', {}, root) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('outside the deployed closure') +}) + +it('rejects an optional WASM engine resolved outside the deployed closure', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + const ambient = await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow(`Python Office dependency is outside the deployed closure: ${ambient}`) + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'arm64'], ['win32', 'x64'], ['linux', 'x64'], +])('copies only the %s/%s engine even when other engines are staged', async (platform, arch) => { + const { staging, destination, packageAt } = await fixture() + const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`) + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) }) + for (const name of names) await packageAt(name) + const expected = `@deepseek-ai/libreoffice-kit-${platform === 'linux' ? 'wasm' : `${platform}-${arch}`}` + const packages = await copyOfficeSidecar(staging, destination, { platform, arch }) + expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + 'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`, + ]) +}) + +it.each(['darwin', 'win32'])('%s requires its native package even when WASM is staged', async (platform) => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + await packageAt('@deepseek-ai/libreoffice-kit-wasm') + await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' })) + .rejects.toThrow(`Python Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`) + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('requires a staged Linux WASM engine even when an ancestor has one', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit') + await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('Python Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/scripts/build-exe-for-python-sdk-office.ts b/scripts/build-exe-for-python-sdk-office.ts new file mode 100644 index 0000000000..50385ed0f5 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-office.ts @@ -0,0 +1,94 @@ +/** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */ +import { existsSync } from 'node:fs' +import { cp, mkdir, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { isAbsolute, join, relative, sep } from 'node:path' + +/** pkg applies these exclusions to dependency `files` as well as root asset globs. */ +export const OFFICE_ASSET_IGNORES = [ + '**/node_modules/@deepseek-ai/libreoffice-kit/**', + '**/node_modules/@deepseek-ai/libreoffice-kit-*/**', +] + +interface PackageManifest { + name: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record + os?: string[] + cpu?: string[] +} + +/** + * Copy the installed Office dependency tree without changing package contents or executable modes. + * Harness sidecars require the target native engine on macOS/Windows and WASM on Linux. + * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail. + * @param staging - Symlink-free deployed Node closure. + * @param destination - Target-specific Office directory beside the executable; replaced when present. + * @param target - Node platform and CPU of the executable. + * @returns Relative package directories included in the sidecar. + */ +export async function copyOfficeSidecar( + staging: string, + destination: string, + target: { platform: string; arch: string }, +): Promise { + const engineName = `@deepseek-ai/libreoffice-kit-${target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`}` + const packages = new Set() + + async function visit(packageDirectory: string): Promise { + if (packages.has(packageDirectory)) return + const relativeDirectory = relative(staging, packageDirectory) + if (isAbsolute(relativeDirectory) || relativeDirectory === '..' || relativeDirectory.startsWith(`..${sep}`)) { + throw new Error(`Python Office dependency is outside the deployed closure: ${packageDirectory}`) + } + const manifestPath = join(packageDirectory, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as PackageManifest + packages.add(packageDirectory) + const require = createRequire(manifestPath) + const dependencies = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ]) + for (const name of dependencies) { + if (name.startsWith('@deepseek-ai/libreoffice-kit-') && name !== engineName) continue + const optional = manifest.optionalDependencies?.[name] !== undefined + || manifest.peerDependenciesMeta?.[name]?.optional === true + const dependencyDirectory = (require.resolve.paths(name) ?? []) + .map(directory => join(directory, name)) + .find(directory => existsSync(directory)) + if (dependencyDirectory === undefined) { + if (optional) continue + throw new Error(`Python Office dependency ${name} required by ${manifest.name} is missing.`) + } + if (optional) { + const dependency = JSON.parse(await readFile(join(dependencyDirectory, 'package.json'), 'utf8')) as PackageManifest + if (!supports(dependency.os, target.platform) || !supports(dependency.cpu, target.arch)) continue + } + await visit(dependencyDirectory) + } + } + + await visit(join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit')) + const engineDirectory = join(staging, 'node_modules', engineName) + if (!existsSync(engineDirectory)) throw new Error(`Python Office engine ${engineName} required for ${target.platform}/${target.arch} is missing.`) + await visit(engineDirectory) + await rm(destination, { recursive: true, force: true }) + await mkdir(destination, { recursive: true }) + const directories = [...packages].sort() + for (const source of directories) { + const nestedModules = join(source, 'node_modules') + await cp(source, join(destination, relative(staging, source)), { + recursive: true, + filter: path => path !== nestedModules && !path.startsWith(nestedModules + sep), + }) + } + return directories.map(directory => relative(staging, directory)) +} + +function supports(values: string[] | undefined, value: string): boolean { + return values === undefined || (!values.includes(`!${value}`) + && (values.every(item => item.startsWith('!')) || values.includes(value) || values.includes('any'))) +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b615666c31..fc06183c91 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -12,6 +12,7 @@ import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, wri import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' +import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts' const root = resolve(import.meta.dirname, '..') @@ -398,7 +399,7 @@ class SingleExeBuild { /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { - const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } } const manifestPath = join(this.staging, 'package.json') if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) @@ -418,7 +419,7 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. + * @returns the executable, Office directory, ripgrep, and required macOS spawn helper paths. */ async pack(target: Target): Promise { const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) @@ -438,8 +439,16 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } + const office = `${productBase}-office` + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`) + } else { + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch }) + console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`) + } const ripgrep = await this.copyRipgrepSidecar(target, product) - if (target.platform !== 'macos') return [product, ripgrep] + if (target.platform !== 'macos') return [product, ripgrep, office] const spawnHelper = `${product}-spawn-helper` const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { @@ -448,7 +457,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, 0o755) } - return [product, ripgrep, spawnHelper] + return [product, ripgrep, spawnHelper, office] } /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ @@ -538,6 +547,10 @@ class SingleExeBuild { console.log(` ${path}`) continue } + if (statSync(path).isDirectory()) { + console.log(` ${path} (Office dependency directory)`) + continue + } const megabytes = statSync(path).size / (1024 * 1024) console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } @@ -559,7 +572,10 @@ class SingleExeBuild { await mkdir(destDir, { recursive: true }) for (const path of products) { const destination = join(destDir, basename(path)) - await copyFile(path, destination) + if (statSync(path).isDirectory()) { + await rm(destination, { recursive: true, force: true }) + await cp(path, destination, { recursive: true }) + } else await copyFile(path, destination) await chmod(destination, statSync(path).mode & 0o777) console.log(`build-exe-for-python-sdk: synced ${destination}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 085974f921..04500c942b 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -55,6 +55,11 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]: return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names +def office_sidecar_name(executable_name: str) -> str: + """Return the complete Office dependency directory for one executable.""" + return f"{executable_name.removesuffix('.exe')}-office" + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -220,6 +225,42 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ source_directory = executable.parent for filename in runtime_filenames(executable_name): shutil.copy2(source_directory / filename, runtime_dir / filename) + office = office_sidecar_name(executable_name) + shutil.copytree(source_directory / office, runtime_dir / office) + + +def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None: + """Check packaged engine assets and native helper executable permissions.""" + names = set(archive.namelist()) + adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json" + if adapter not in names: + raise RuntimeError("Office dependency is missing: libreoffice-kit") + engines = f"{office_modules}/@deepseek-ai" + target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag) + selected = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") + for required in (f"libreoffice-kit-{selected}/prebuilds.json",): + if f"{engines}/{required}" not in names: + raise RuntimeError(f"Office dependency is missing: {required}") + manifests = ( + name for name in names + if name.startswith(f"{engines}/libreoffice-kit-") + and name.endswith("/prebuilds.json") + and name.count("/") == engines.count("/") + 2 + ) + for manifest_path in manifests: + if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json": + raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}") + manifest = json.loads(archive.read(manifest_path)) + engine = manifest["engine"] + native = engine["kind"] == "native" + fields = ("executable",) if native else ("loader", "wasm", "data", "metadata") + for field in fields: + asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}" + if asset not in names: + raise RuntimeError(f"Office engine asset is missing: {asset}") + mode = archive.getinfo(asset).external_attr >> 16 + if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0: + raise RuntimeError(f"Office helper lost its executable bit: {asset}") def verify_wheel( @@ -253,21 +294,26 @@ def verify_wheel( raise RuntimeError( f"{wheel} has license files {license_files}, expected {expected_license_files}" ) - runtime_files = [ + runtime_payload = [ name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name ] if package == "runtime": assert platform is not None - expected_files = sorted(runtime_filenames(platform[1])) - found_files = sorted(Path(name).name for name in runtime_files) + office = office_sidecar_name(platform[1]) + expected_files = sorted((*runtime_filenames(platform[1]), office)) + found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload}) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") - for runtime_file in runtime_files: + office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules" + verify_office_payload(archive, office_modules, platform[0]) + for runtime_file in runtime_payload: + if "/" in runtime_file.split("/runtime/", 1)[1]: + continue mode = archive.getinfo(runtime_file).external_attr >> 16 if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") - elif runtime_files: - raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") + elif runtime_payload: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime files: {runtime_payload}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" diff --git a/scripts/fixtures/python-sdk-office.mjs b/scripts/fixtures/python-sdk-office.mjs new file mode 100644 index 0000000000..e7922a2399 --- /dev/null +++ b/scripts/fixtures/python-sdk-office.mjs @@ -0,0 +1,15 @@ +/** Exercise the wheel's external Office package from a shipped dsh profile. */ +import { writeFile } from 'node:fs/promises' +import { createConverter } from '@deepseek-ai/libreoffice-kit' + +export const name = 'python-sdk-office-smoke' + +export async function apply(_ctx, config) { + const converter = await createConverter({ timeoutMs: 120_000 }) + try { + const result = await converter.render({ inputPath: config.input, outputPath: config.output }) + await writeFile(config.result, JSON.stringify({ ...result, moduleUrl: import.meta.resolve('@deepseek-ai/libreoffice-kit') })) + } finally { + await converter.dispose() + } +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4ac777ee4a..809a1b49d2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', + documentRender: 'document-render.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -273,6 +274,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', + DocumentRenderRequest: 'document-render.md', + DocumentRenderResult: 'document-render.md', + DocumentRenderPriority: 'document-render.md', + DocumentRendererGeneration: 'document-render.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9aa044a0bb..4878686426 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -63,6 +63,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' const GROUP_ORDER = [ 'util', 'attachment', + 'document', 'llm', 'core', 'typert', @@ -149,6 +150,11 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'], note: '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.', }, + { + key: 'documentRender', pkg: 'document-render', title: 'Office to PDF rendering', + mode: 'seam', implementations: ['document-render-libreoffice'], consumers: [], + note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', + }, { key: 'attachments', pkg: 'attachment', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 1bfbe99e31..3ab18f9291 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -34,6 +34,8 @@ describe('THIRD_PARTY_NOTICES.md', () => { const generated = await render() expect(generated).toContain('It depends on the third-party software listed below.') expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) + expect(generated).toContain('## LibreOffice conversion kit') + expect(generated).toContain('Recipients must have access to those corresponding sources and notices.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) }) @@ -49,6 +51,27 @@ function workspace(entries: Record): { manifests: Map { + it('limits the LibreOffice exception to its reviewed package identity and MPL terms', () => { + for (const name of [ + '@deepseek-ai/libreoffice-kit', '@deepseek-ai/libreoffice-kit-wasm', + '@deepseek-ai/libreoffice-kit-darwin-arm64', '@deepseek-ai/libreoffice-kit-darwin-x64', + '@deepseek-ai/libreoffice-kit-win32-arm64', '@deepseek-ai/libreoffice-kit-win32-x64', + ]) { + expect(() => { assertRuntimeLicenses([{ name, license: 'MPL-2.0' }]) }).not.toThrow() + expect(() => { assertRuntimeLicenses([{ name, license: 'GPL-3.0-only' }]) }).toThrow(name) + } + for (const dependency of [ + { name: 'unrelated-library', license: 'MPL-2.0' }, + { name: '@deepseek-ai/dsh-libreoffice-kit', license: 'MPL-2.0' }, + { name: '@deepseek-ai/libreoffice-kit-unreviewed', license: 'MPL-2.0' }, + { name: '@deepseek-ai/libreoffice-kit', license: 'GPL-3.0-only' }, + { name: '@deepseek-ai/libreoffice-kit', license: 'UNKNOWN' }, + ]) { + expect(() => { assertRuntimeLicenses([dependency]) }).toThrow(`${dependency.name} (${dependency.license})`) + } + expect(isPermissive('MPL-2.0')).toBe(false) + }) + it('keeps license rejection active when a browser library is declared for development', () => { const { manifests, names } = workspace({ 'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } }, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f0ff461f8c..c77fbd6b38 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -53,6 +53,15 @@ const FIRST_PARTY = new Set([ export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' +const LIBREOFFICE_KIT_PACKAGE = '@deepseek-ai/libreoffice-kit' +const LIBREOFFICE_PACKAGES = new Set([ + LIBREOFFICE_KIT_PACKAGE, + '@deepseek-ai/libreoffice-kit-wasm', + '@deepseek-ai/libreoffice-kit-darwin-arm64', + '@deepseek-ai/libreoffice-kit-darwin-x64', + '@deepseek-ai/libreoffice-kit-win32-arm64', + '@deepseek-ai/libreoffice-kit-win32-x64', +]) /** * Whether a non-permissive runtime declaration has an identity-scoped owner @@ -680,7 +689,9 @@ export function isPermissive(license: string): boolean { * @throws When a runtime package has no permissive license or exact owner authorization. */ export function assertRuntimeLicenses(dependencies: readonly { name: string; license: string }[]): void { - const rejected = dependencies.filter(dep => !isPermissive(dep.license) && !isOwnerAuthorizedRuntime(dep.name)) + const rejected = dependencies.filter(dep => !isPermissive(dep.license) + && !isOwnerAuthorizedRuntime(dep.name) + && !(LIBREOFFICE_PACKAGES.has(dep.name) && dep.license === 'MPL-2.0')) if (rejected.length > 0) { throw new Error(`gen-third-party-notices: runtime ${rejected.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) } @@ -739,6 +750,7 @@ export async function render(): Promise { const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) + const kitRuntime = runtimeDeps.some(dep => dep.name === LIBREOFFICE_KIT_PACKAGE) const vendored = collectVendored() const python = collectPython() const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) @@ -782,6 +794,13 @@ pnpm applies local patches to the following packages at install time, so shipped ${patchedLines.join('\n')} ${renderClaudeDistribution(claudeDistribution)} +${kitRuntime ? ` +## LibreOffice conversion kit + +${[...LIBREOFFICE_PACKAGES].map(name => `\`${name}\``).join(', ')} declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. +` : ''} ## Development-only npm dependencies diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index afd1cc4cb7..6f551d6773 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -19,6 +19,7 @@ import sysconfig import tempfile import threading import time +import zipfile from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import TYPE_CHECKING, Callable @@ -776,7 +777,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "runner", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-live", "runner", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -795,13 +796,20 @@ def main() -> None: parser.error("--scenario sdk-profile-plugin requires --installed-wheel") if args.installed_wheel: args.exe = assert_installed_wheel_environment() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "runner", "direct"} and args.exe is None: - parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, runner, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "runner", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart"}: parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") + if args.scenario in {"all", "sdk-office"}: + assert args.exe is not None + smoke_sdk_office(args.exe.resolve()) + if args.scenario == "sdk-office": + print("smoke-python-runtime: sdk-office passed") + return + if args.scenario in {"all", "runner"}: assert args.exe is not None smoke_packaged_runner(args.exe.resolve()) @@ -850,6 +858,69 @@ def main() -> None: print(f"smoke-python-runtime: {args.scenario} passed") +def smoke_sdk_office(executable: Path) -> None: + """Relocate the wheel payload and convert a real DOCX with the target platform engine.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-office-") as temporary: + root = Path(temporary).resolve() + relocated = root / executable.name + stem = executable.name.removesuffix(".exe") + for source in executable.parent.glob(f"{stem}*"): + destination = root / source.name + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + office = root / f"{stem}-office" + expected_backend = "wasm" if sys.platform == "linux" else "native" + engines = [ + json.loads(manifest.read_text())["engine"]["kind"] + for manifest in (office / "node_modules/@deepseek-ai").glob("libreoffice-kit-*/prebuilds.json") + ] + if engines != [expected_backend]: + raise AssertionError(f"Office sidecar must contain only {expected_backend}: {engines}") + plugin = root / "office.mjs" + shutil.copy2(Path(__file__).resolve().parent / "fixtures/python-sdk-office.mjs", plugin) + document = root / "document.docx" + with zipfile.ZipFile(document, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("[Content_Types].xml", '') + archive.writestr("_rels/.rels", '') + archive.writestr("word/document.xml", 'Python Office wheel') + + mode = expected_backend + output = root / f"{mode}.pdf" + result_path = root / f"{mode}.json" + patch = root / f"{mode}.patch.yml" + patch.write_text(json.dumps([{"insert": [{ + "id": "python-sdk-office-smoke", + "name": plugin.as_uri(), + "config": {"input": str(document), "output": str(output), "result": str(result_path)}, + }]}])) + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + dsh_bin=str(relocated), + dsh_home=str(root / f"home-{mode}"), + patches=(str(patch),), + api_key="sk-keyless-smoke", + base_url="http://127.0.0.1:9", + env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"}, + request_timeout_seconds=180, + ): + pass + result = json.loads(result_path.read_text()) + if result["backend"] != expected_backend: + raise AssertionError(f"Office conversion did not use {expected_backend}: {result}") + if not result["moduleUrl"].startswith(office.as_uri() + "/"): + raise AssertionError(f"Office module was not loaded from the relocated wheel: {result}") + pdf = output.read_bytes() + if len(pdf) < 100 or not pdf.startswith(b"%PDF-"): + raise AssertionError(f"Office conversion produced an invalid PDF at {output}") + print(f"smoke-python-runtime: relocated Office {result['backend']} DOCX produced {len(pdf)} PDF bytes") + + def assert_installed_wheel_environment() -> Path: """Prove that this process imports matching non-editable wheel installations.""" if sys.prefix == sys.base_prefix: diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index cfe65c4bb1..f33f5c00ba 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -51,6 +51,27 @@ afterEach(() => { }) describe('default product isolation', () => { + it.each(['@deepseek-ai/libreoffice-kit'])( + 'accepts independently published %s but rejects unknown workspace packages', (name) => { + const root = fixture() + const file = 'packages/core/core/package.json' + write(root, 'packages/core/core/src/index.ts', `import '${name}'\n`) + manifest(root, file, { dependencies: { [name]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures).toEqual([]) + manifest(root, file, { dependencies: { [`${name}-unknown`]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain('unknown workspace package') + }, + ) + + it.each(['wasm', 'darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64'])( + 'rejects a direct dependency on the %s engine', (engine) => { + const root = fixture() + const name = `@deepseek-ai/libreoffice-kit-${engine}` + manifest(root, 'packages/core/core/package.json', { dependencies: { [name]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`unknown workspace package ${name}`) + }, + ) + it('allows development dependencies, type imports, and separate experimental preview entries', () => { const root = fixture() manifest(root, 'apps/cli/package.json', { devDependencies: { [experimental]: 'workspace:^' } }) diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index 45576d5e8e..5e3acc7fc9 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -20,6 +20,8 @@ import { } from './verify-client-packages.ts' const EXPERIMENTAL_PREFIX = '@deepseek-ai/dsh-experimental-' +// The independently published entry package owns platform-engine dependencies. +const EXTERNAL_KIT_PACKAGES = new Set(['@deepseek-ai/libreoffice-kit']) const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts' const PRESET_PATTERN = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const @@ -129,6 +131,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes } const pkg = packages.get(packageName) if (pkg !== undefined) add(pkg, origin) + else if (EXTERNAL_KIT_PACKAGES.has(packageName)) return else if (packageName.startsWith('@deepseek-ai/')) failures.push(`${origin}: unknown workspace package ${name}`) } const dependency = (name: string, range: string, owner: Package, origin: string): void => { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 465d8eb813..90e9039d96 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,6 +53,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, + 'packages/document/document-render': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, + 'packages/document/document-render-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/scripts/verify-repository-references.spec.ts b/scripts/verify-repository-references.spec.ts index f01ea346af..8b2f0a9d3c 100644 --- a/scripts/verify-repository-references.spec.ts +++ b/scripts/verify-repository-references.spec.ts @@ -44,6 +44,15 @@ function repository(test: TestContext) { } describe('maintained repository reference policy', () => { + it('permits only the independent kit repository and its source URLs', () => { + for (const suffix of ['', '.git', '/tree/main/packages/entry']) { + expect(findRepositoryReferences('package.json', `${organizationUrl}/libreoffice-kit${suffix}`, new Set())).toEqual([]) + } + for (const suffix of ['-other', '.example', 's']) { + expect(findRepositoryReferences('package.json', `${organizationUrl}/libreoffice-kit${suffix}`, new Set())).toHaveLength(1) + } + }) + it('rejects complete and abbreviated commit identifiers in tracked, staged, and new files', (test) => { const fixture = repository(test) fixture.write('tracked.md', `release\n${fixture.commit}\n${fixture.commit.toUpperCase()}\n`) diff --git a/scripts/verify-repository-references.ts b/scripts/verify-repository-references.ts index 4f7ddcb563..92ad6abacc 100644 --- a/scripts/verify-repository-references.ts +++ b/scripts/verify-repository-references.ts @@ -9,6 +9,8 @@ import { canonicalReferenceText } from './verify-public-repository-links.ts' const root = resolve(import.meta.dirname, '..') const organization = ['deepseek', 'harness'].join('-') const organizationUrl = new RegExp(`\\bgithub\\.com/${organization}(?![a-z0-9-])`) +// The independent kit repository owns the engine source and documentation. +const kitRepositoryUrl = new RegExp(`\\bgithub\\.com/${organization}/libreoffice-kit(?:\\.git)?(?=/|[^a-zA-Z0-9_.-]|$)`, 'g') const commitCandidate = /(? commits.has(match[0].toLowerCase()))) { diff --git a/tsconfig.base.json b/tsconfig.base.json index b55a3f3e6e..ef3f69896f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -324,6 +324,8 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], + "@deepseek-ai/dsh-document-render": ["./packages/document/document-render/src"], + "@deepseek-ai/dsh-document-render-libreoffice": ["./packages/document/document-render-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index cf53223326..4948cf5195 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -158,6 +158,8 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, + { "path": "./packages/document/document-render" }, + { "path": "./packages/document/document-render-libreoffice" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From fd3cdf539bd8a3feeed903e29951a377edea4527 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 13:59:49 +0800 Subject: [PATCH 02/18] refactor(document): name Office PDF conversion explicitly --- ...9-14-independent-libreoffice-kit.i18n.yaml | 4 +- .../2026-09-14-independent-libreoffice-kit.md | 2 +- ...26-09-14-independent-libreoffice-kit.zh.md | 2 +- ...6-09-15-bounded-office-rendering.i18n.yaml | 4 +- .../2026-09-15-bounded-office-rendering.md | 4 +- .../2026-09-15-bounded-office-rendering.zh.md | 4 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 12 +-- docs/capability-seams.zh.md | 12 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- ...r.i18n.yaml => document-convert.i18n.yaml} | 6 +- ...document-render.md => document-convert.md} | 26 +++---- ...nt-render.zh.md => document-convert.zh.md} | 26 +++---- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 4 +- .../web-app/tests/document-conversion.e2e.ts | 24 +++--- packages/document/README.i18n.yaml | 4 +- packages/document/README.md | 12 +-- packages/document/README.zh.md | 12 +-- .../README.i18n.yaml | 6 +- .../README.md | 10 +-- .../README.zh.md | 10 +-- .../package.json | 8 +- .../src/index.ts | 30 +++---- .../src/output.ts | 12 +-- .../src/queue.ts | 50 ++++++------ .../tests/output.spec.ts | 0 .../tests/provider.spec.ts | 78 +++++++++---------- .../tests/queue.spec.ts | 20 ++--- .../tsconfig.json | 2 +- .../README.i18n.yaml | 6 +- .../README.md | 8 +- .../README.zh.md | 8 +- .../package.json | 4 +- .../document/document-convert/src/index.ts | 44 +++++++++++ .../src/types.ts | 28 +++---- .../tsconfig.json | 0 .../document/document-render/src/index.ts | 44 ----------- .../node/external_packages/libreoffice-kit.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 50 ++++++------ pnpm-lock.yaml | 16 ++-- scripts/gen-cordis-catalog.ts | 10 +-- scripts/gen-doc-graphs.ts | 4 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 5 +- tsconfig.host.json | 4 +- 57 files changed, 339 insertions(+), 334 deletions(-) rename docs/subsystems/{document-render.i18n.yaml => document-convert.i18n.yaml} (67%) rename docs/subsystems/{document-render.md => document-convert.md} (59%) rename docs/subsystems/{document-render.zh.md => document-convert.zh.md} (58%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.i18n.yaml (67%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.md (79%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.zh.md (88%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/package.json (80%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/index.ts (84%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/output.ts (69%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/queue.ts (82%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/output.spec.ts (100%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/provider.spec.ts (80%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/queue.spec.ts (96%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tsconfig.json (89%) rename packages/document/{document-render => document-convert}/README.i18n.yaml (69%) rename packages/document/{document-render => document-convert}/README.md (69%) rename packages/document/{document-render => document-convert}/README.zh.md (67%) rename packages/document/{document-render => document-convert}/package.json (90%) create mode 100644 packages/document/document-convert/src/index.ts rename packages/document/{document-render => document-convert}/src/types.ts (77%) rename packages/document/{document-render => document-convert}/tsconfig.json (100%) delete mode 100644 packages/document/document-render/src/index.ts diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml index cb10a38c27..7f6119ae93 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md -2026-09-14-independent-libreoffice-kit.md: aaecb7468c16451ed812c9b2441e55e00abf6c98 -2026-09-14-independent-libreoffice-kit.zh.md: 8cea9890596bc53ddcbe346c60c654b49e0362c8 +2026-09-14-independent-libreoffice-kit.md: f9f27585d157025477e5f5d5f6d74dfca142ce83 +2026-09-14-independent-libreoffice-kit.zh.md: 18bf20e28c8f66ea581ac7823f7b004e89153b90 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md index aaecb7468c..f9f27585d1 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -10,7 +10,7 @@ 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 `DocumentRenderer` 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 `DocumentConverter` 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. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md index 8cea989059..18bf20e28c 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -10,7 +10,7 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布 ## Decision -`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentRenderer` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentConverter` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml index 44528f3fc0..9cb08e42e0 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: 539eb7e4824897861f350f294365f283678a7fee -2026-09-15-bounded-office-rendering.zh.md: 928d93a6500d6c7818f28dd20029648453d9f9cf +2026-09-15-bounded-office-rendering.md: c8ffd5f476e9c5a7c7a0f85706872b2da1690681 +2026-09-15-bounded-office-rendering.zh.md: a03a605dd66142d6457d35154a0e1628dcfa7632 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md index 539eb7e482..c8ffd5f476 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -10,7 +10,9 @@ Office preview and explicit document inspection can request the same conversion. ## Decision -The [Host provider](../../../../packages/document/document-render-libreoffice/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 renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +The `document-convert` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. + +The [Host provider](../../../../packages/document/document-convert-libreoffice/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 renderer 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. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md index 928d93a650..a03a605dd6 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -10,7 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 ## 决策 -[宿主提供方](../../../../packages/document/document-render-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +`document-convert` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 + +[宿主提供方](../../../../packages/document/document-convert-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index edaa3ccd21..2fa729e15a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 7d89ad7f5be786c74fe4584a1c29e64a181e7b2e -README.zh.md: 3eb1bcb570ac8d9056573ba5e10e85b9df06546a +README.md: f08fab5911450a615fc9b5cf750268b029a24f5d +README.zh.md: d82a4f21e1bfcd9fbc56376033e4811bf4bc644d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7d89ad7f5b..f08fab5911 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -116,7 +116,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-render-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. 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; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 3eb1bcb570..d82a4f21e1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -116,7 +116,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-render-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5503477cb6..69c6a8af46 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 8cfa30e16a3ef782719d2360f2e6cbdc4a4560b8 -capability-seams.zh.md: a709b14dd36f0f0b2f7be036cf99603f69631716 +capability-seams.md: ebe0bb074ad2d0c3739b83ddb3f08d1165e85277 +capability-seams.zh.md: 16707057fcd3f654c635f950c5f7fb77f23a3248 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 8cfa30e16a..ebe0bb074a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,9 +26,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_render["document-render"] - svc_documentRender["ctx.documentRender
Office to PDF rendering"] - pkg_document_render_libreoffice["document-render-libreoffice"] + pkg_document_convert["document-convert"] + svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -283,8 +283,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_render --> svc_documentRender - pkg_document_render_libreoffice --> svc_documentRender + pkg_document_convert --> svc_documentConvert + pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -533,7 +533,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `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.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | | `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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a709b14dd3..16707057fc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,9 +28,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_render["document-render"] - svc_documentRender["ctx.documentRender
Office to PDF rendering"] - pkg_document_render_libreoffice["document-render-libreoffice"] + pkg_document_convert["document-convert"] + svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -285,8 +285,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_render --> svc_documentRender - pkg_document_render_libreoffice --> svc_documentRender + pkg_document_convert --> svc_documentConvert + pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -535,7 +535,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `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.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 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(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 710d64fdc1..87f9f1c3dc 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 58eb43a6fea63c8e4ef85e22d45d89c875c62a2e -config-catalog.zh.md: 78de098bee885fb9133264a80fb16c50ed373dc0 +config-catalog.md: ffe4d39af3a87b76d4bc6100c9575d8d6975696d +config-catalog.zh.md: 62031ac825baf929b4465a1c608978558facf3c8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 58eb43a6fe..ffe4d39af3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,9 +548,9 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - + -## `@deepseek-ai/dsh-document-render-libreoffice` +## `@deepseek-ai/dsh-document-convert-libreoffice` ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -596,7 +596,7 @@ export interface Config { } ``` -Source: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) +Source: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) @@ -3789,7 +3789,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-render` — abstract `DocumentRenderer` ([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) +- `@deepseek-ai/dsh-document-convert` — abstract `DocumentConverter` ([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 78de098bee..62031ac825 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,9 +550,9 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - + -## `@deepseek-ai/dsh-document-render-libreoffice` +## `@deepseek-ai/dsh-document-convert-libreoffice` ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -598,7 +598,7 @@ export interface Config { } ``` -源码: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) +源码: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) @@ -3791,7 +3791,7 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-render` — 抽象 `DocumentRenderer`([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) +- `@deepseek-ai/dsh-document-convert` — 抽象 `DocumentConverter`([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 54fba8a4d3..7b8d06b446 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 7f39f52de2cd8fca3bbcb9973e7980de979d4385 -README.zh.md: facb05de4078b8033178e40e2ae18424b9183938 +README.md: f3593271de63f0436ccd0e704e787c7436b1ac00 +README.zh.md: 4f5ffba4b757e2d835a630cea14c09c7ecdb4980 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 7f39f52de2..f3593271de 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -29,7 +29,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 | -| [document-render.md](document-render.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | +| [document-convert.md](document-convert.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 | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index facb05de40..4f5ffba4b7 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -29,7 +29,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 约定 | -| [document-render.md](document-render.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | +| [document-convert.md](document-convert.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_*` 环境词汇 | diff --git a/docs/subsystems/document-render.i18n.yaml b/docs/subsystems/document-convert.i18n.yaml similarity index 67% rename from docs/subsystems/document-render.i18n.yaml rename to docs/subsystems/document-convert.i18n.yaml index ab3117feae..8ec3e22776 100644 --- a/docs/subsystems/document-render.i18n.yaml +++ b/docs/subsystems/document-convert.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/document-render.md -document-render.md: f3e49fb45f2cc19956b6385cf7390825fa9d4c60 -document-render.zh.md: 79028858e9e3f95ac1a4618d3b9342b78aa29cf2 +# pnpm run verify-translation-pairing --write docs/subsystems/document-convert.md +document-convert.md: 2dea31918e92524606ad85826de276c2af05f06d +document-convert.zh.md: 22f0ec54615ebbf86743de6320ec4e8cc3fc8113 diff --git a/docs/subsystems/document-render.md b/docs/subsystems/document-convert.md similarity index 59% rename from docs/subsystems/document-render.md rename to docs/subsystems/document-convert.md index f3e49fb45f..2dea31918e 100644 --- a/docs/subsystems/document-render.md +++ b/docs/subsystems/document-convert.md @@ -1,6 +1,6 @@ -# Document rendering +# Document conversion -English | [中文](document-render.zh.md) +English | [中文](document-convert.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,21 +8,21 @@ The [document package family](../../packages/document/README.md) converts Office | Owner | Responsibility | |---|---| -| [document-render](../../packages/document/document-render/README.md) | `ctx.documentRender`: authorized Office bytes and complete PDF results | -| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [document-convert](../../packages/document/document-convert/README.md) | `ctx.documentConvert`: authorized Office bytes and complete PDF results | +| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | ## Requests and results -[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentRenderer.render(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentRenderError`. +[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentConverter.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentConvertError`. -`DocumentRenderPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentRendererGeneration` brands a provider lifetime, and `DocumentRenderKey` brands its content identity; neither opaque value is parsed by consumers. +`DocumentConvertPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentConverterGeneration` brands a provider lifetime, and `DocumentConvertKey` brands its content identity; neither opaque value is parsed by consumers. | Result field | Meaning | |---|---| | `pdf` | Caller-owned `Uint8Array` containing the complete PDF | | `missingFonts` | Requested document font families unavailable to this conversion | -| `cacheKey` | Opaque renderer generation plus extension/source-content identity | +| `cacheKey` | Opaque converter generation plus extension/source-content identity | | `generation` | Provider lifetime; replacement invalidates cached PDF reuse | The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. @@ -31,7 +31,7 @@ The provider admits the deferred read before allocating source bytes, shares con The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/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. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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 [Host provider configuration](../../packages/document/document-render-libreoffice/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/document-convert-libreoffice/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. @@ -41,9 +41,9 @@ The [Host provider configuration](../../packages/document/document-render-libreo Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentRender` — `DocumentRenderer` (abstract seam) +### `ctx.documentConvert` — `DocumentConverter` (abstract seam) Load one provider subclass per context; consumers own source authorization. @@ -53,10 +53,10 @@ Load one provider subclass per context; consumers own source authorization. * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) +Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) diff --git a/docs/subsystems/document-render.zh.md b/docs/subsystems/document-convert.zh.md similarity index 58% rename from docs/subsystems/document-render.zh.md rename to docs/subsystems/document-convert.zh.md index 79028858e9..22f0ec5461 100644 --- a/docs/subsystems/document-render.zh.md +++ b/docs/subsystems/document-convert.zh.md @@ -1,6 +1,6 @@ -# 文档渲染 +# 文档转换 -[English](document-render.md) | 中文 +[English](document-convert.md) | 中文 [document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 @@ -8,21 +8,21 @@ | 所有者 | 职责 | |---|---| -| [document-render](../../packages/document/document-render/README.zh.md) | `ctx.documentRender`:已授权 Office 字节和完整 PDF 结果 | -| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [document-convert](../../packages/document/document-convert/README.zh.md) | `ctx.documentConvert`:已授权 Office 字节和完整 PDF 结果 | +| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | ## 请求和结果 -[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentRenderer.render(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentRenderError` 拒绝。 +[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentConverter.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentConvertError` 拒绝。 -`DocumentRenderPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentRendererGeneration` 表示提供方生命周期,`DocumentRenderKey` 表示其内容身份;消费者不解析这两种不透明值。 +`DocumentConvertPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentConverterGeneration` 表示提供方生命周期,`DocumentConvertKey` 表示其内容身份;消费者不解析这两种不透明值。 | 结果字段 | 含义 | |---|---| | `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | | `missingFonts` | 本次转换无法使用的文档请求字体名称 | -| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 | +| `cacheKey` | 不透明的转换 generation 加扩展名与源内容身份 | | `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | 提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 @@ -31,7 +31,7 @@ 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 -[Host 提供方配置](../../packages/document/document-render-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 +[Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 @@ -41,9 +41,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). - + -### `ctx.documentRender` — `DocumentRenderer` (abstract seam) +### `ctx.documentConvert` — `DocumentConverter` (abstract seam) Load one provider subclass per context; consumers own source authorization. @@ -53,10 +53,10 @@ Load one provider subclass per context; consumers own source authorization. * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) +Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 807ca3fb09..c2f7020c88 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 0057516fb5bfba2708923e08af20f6e59f53f772 -README.zh.md: 1be494520d18311cae1f8dfca00c682b597f889d +README.md: 2b5c4f48f95fc40b7e25c33e4b14a510c0ace34f +README.zh.md: a8117f8f7d1c3f12016d37c37b81e5a8734531aa diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 0057516fb5..2b5c4f48f9 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-render` row mounts one lazy [Office conversion provider](../../document/document-render-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. +The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-convert` row mounts one lazy [Office conversion provider](../../document/document-convert-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 1be494520d..a8117f8f7d 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-render` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-render-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-convert` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-convert-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 0a5aba45a5..120ae2a0c4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,8 +225,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - - id: document-render - name: '@deepseek-ai/dsh-document-render-libreoffice' + - id: document-convert + name: '@deepseek-ai/dsh-document-convert-libreoffice' # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 5e8ee68258..a31ffaab1e 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -122,8 +122,8 @@ "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^", - "@deepseek-ai/dsh-document-render-libreoffice": "workspace:^" + "@deepseek-ai/dsh-document-convert": "workspace:^", + "@deepseek-ai/dsh-document-convert-libreoffice": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts index 3cab818548..2f66681703 100644 --- a/packages/bundle/web-app/tests/document-conversion.e2e.ts +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -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 { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' -import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-render-libreoffice' +import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-convert-libreoffice' 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-document-render-libreoffice') - expect(rows.map(row => row.id)).toEqual(['document-render']) - const configured = applyEntryPatches(rows, [{ id: 'document-render', config: { maxConcurrentConversions: 1 } }], + .filter(row => row.name === '@deepseek-ai/dsh-document-convert-libreoffice') + expect(rows.map(row => row.id)).toEqual(['document-convert']) + const configured = applyEntryPatches(rows, [{ id: 'document-convert', 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-document-render-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + if (specifier !== '@deepseek-ai/dsh-document-convert-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) return LibreOfficeProvider }, } as unknown as NonNullable 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 === 'document-render')! + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-convert')! await entry.fiber!.await() expect(ctx.get('documentRenderController')).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): DocumentRenderRequest => ({ + const request = (key: string): DocumentConvertRequest => ({ extension: 'docx', priority: 'foreground', source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, async read(signal, maxBytes) { @@ -56,8 +56,8 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }, }, }) - const provider = ctx.documentRender - const [first, second] = await Promise.all([provider.render(request('first')), provider.render(request('second'))]) + const provider = ctx.documentConvert + 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-') expect(first.cacheKey).toBe(second.cacheKey) @@ -66,11 +66,11 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal expect(first.missingFonts).not.toBe(second.missingFonts) first.pdf.fill(0) first.missingFonts.push('caller-owned') - const cached = await provider.render(request('first')) + const cached = await provider.convert(request('first')) expect(reads).toBe(2) expect(cached.pdf).toEqual(second.pdf) expect(cached.missingFonts).toEqual(second.missingFonts) await ctx.fiber.dispose() expect(Buffer.from(cached.pdf).subarray(0, 5).toString()).toBe('%PDF-') - await expect(provider.render(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) + await expect(provider.convert(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) }) diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index d31937a0b5..5c7acd04ef 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/document/README.md -README.md: 4ccf8e9c7267b7442ee08a218b51e50a4909aae3 -README.zh.md: c91beff7d3f6a028d9a1553308714c0328ab35de +README.md: 0f9d3f1ae0cb154a445e34debe0bf49ef5400d1e +README.zh.md: 0325634d6c435b6d8aeebf202d04f84fa4975e33 diff --git a/packages/document/README.md b/packages/document/README.md index 4ccf8e9c72..0f9d3f1ae0 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -3,13 +3,13 @@ description: "Package map for Host Office conversion and reusable PDF results." kind: "package-group" --- -# document/ — Office rendering +# document/ — Office conversion English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The rendering family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. ## Table of Contents @@ -22,12 +22,12 @@ Convert authorized Office files to reusable PDFs on the Host. The rendering fami ## Packages -Each package owns its configuration and lifetime rules; the subsystem reference describes their shared rendering operation. +Each package owns its configuration and lifetime rules; the subsystem reference describes their shared conversion operation. | Package | Role | ctx key | |---|---|---| -| [document-render](document-render/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentRender` | -| [document-render-libreoffice](document-render-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentRender` | +| [document-convert](document-convert/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentConvert` | +| [document-convert-libreoffice](document-convert-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentConvert` | ----- @@ -36,7 +36,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference Consumers own source authorization and presentation. -- [Document rendering](../../docs/subsystems/document-render.md) — shared operation and generated service reference. +- [Document conversion](../../docs/subsystems/document-convert.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. diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index c91beff7d3..0325634d6c 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -3,13 +3,13 @@ description: "Host Office 转换和可复用 PDF 结果的包索引。" kind: "package-group" --- -# document/ — Office 渲染 +# document/ — Office 转换 [English](README.md) | 中文 ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。渲染包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 ## 目录 @@ -22,12 +22,12 @@ kind: "package-group" ## 包 -每个包负责自身配置和生命周期规则;子系统参考描述共享渲染操作。 +每个包负责自身配置和生命周期规则;子系统参考描述共享转换操作。 | 包 | 职责 | ctx 键 | |---|---|---| -| [document-render](document-render/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentRender` | -| [document-render-libreoffice](document-render-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentRender` | +| [document-convert](document-convert/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentConvert` | +| [document-convert-libreoffice](document-convert-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentConvert` | ----- @@ -36,7 +36,7 @@ kind: "package-group" 消费者负责源文件授权与展示。 -- [文档渲染](../../docs/subsystems/document-render.zh.md) — 共享操作和生成的服务参考。 +- [文档转换](../../docs/subsystems/document-convert.zh.md) — 共享操作和生成的服务参考。 - [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 - [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 diff --git a/packages/document/document-render-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml similarity index 67% rename from packages/document/document-render-libreoffice/README.i18n.yaml rename to packages/document/document-convert-libreoffice/README.i18n.yaml index 1fde4e33b9..13f28d09b0 100644 --- a/packages/document/document-render-libreoffice/README.i18n.yaml +++ b/packages/document/document-convert-libreoffice/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/document/document-render-libreoffice/README.md -README.md: 3315498c928a5613809ad3f75a1d9fe3e4bc07d2 -README.zh.md: e39f5824d5f969d997914848ebdd6dc767186dc7 +# pnpm run verify-translation-pairing --write packages/document/document-convert-libreoffice/README.md +README.md: 1c287b714ace289fa03f8be6da722fbe78e64065 +README.zh.md: 796d14c3bd873a3a6a24d4691f0d406d0894a122 diff --git a/packages/document/document-render-libreoffice/README.md b/packages/document/document-convert-libreoffice/README.md similarity index 79% rename from packages/document/document-render-libreoffice/README.md rename to packages/document/document-convert-libreoffice/README.md index 3315498c92..1c287b714a 100644 --- a/packages/document/document-render-libreoffice/README.md +++ b/packages/document/document-convert-libreoffice/README.md @@ -3,7 +3,7 @@ description: "Host Office conversion with the independently published LibreOffic kind: "package-reference" --- -# @deepseek-ai/dsh-document-render-libreoffice +# @deepseek-ai/dsh-document-convert-libreoffice English | [中文](README.zh.md) @@ -25,7 +25,7 @@ Convert Office documents to PDFs on the Host computer. macOS and Windows use the ## Use this package -The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-render`. Independent compositions mount `@deepseek-ai/dsh-document-render-libreoffice` as a `cordis.yml` row. +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. @@ -38,9 +38,9 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k | `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-document-render-libreoffice) 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 [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-convert-libreoffice) 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 provider retains successful PDFs by renderer 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. +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. @@ -65,7 +65,7 @@ Each concurrent slot lazily creates and reuses one kit converter. The provider w ## Further Exploration -- [Rendering service](../document-render/README.md) — input and result ownership. +- [Rendering service](../document-convert/README.md) — input and result ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-render-libreoffice/README.zh.md b/packages/document/document-convert-libreoffice/README.zh.md similarity index 88% rename from packages/document/document-render-libreoffice/README.zh.md rename to packages/document/document-convert-libreoffice/README.zh.md index e39f5824d5..796d14c3bd 100644 --- a/packages/document/document-render-libreoffice/README.zh.md +++ b/packages/document/document-convert-libreoffice/README.zh.md @@ -3,7 +3,7 @@ description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文 kind: "package-reference" --- -# @deepseek-ai/dsh-document-render-libreoffice +# @deepseek-ai/dsh-document-convert-libreoffice [English](README.md) | 中文 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -[Web bundle](../../bundle/web-app/README.zh.md)以 `document-render` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-render-libreoffice`。 +[Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 @@ -38,9 +38,9 @@ kind: "package-reference" | `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | | `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | -[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-render-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-convert-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 -提供方按渲染 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 +提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 @@ -65,7 +65,7 @@ kind: "package-reference" ## 延伸阅读 -- [渲染服务](../document-render/README.zh.md) — 输入和结果所有权。 +- [渲染服务](../document-convert/README.zh.md) — 输入和结果所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-render-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json similarity index 80% rename from packages/document/document-render-libreoffice/package.json rename to packages/document/document-convert-libreoffice/package.json index 3adb8d4203..f16000844f 100644 --- a/packages/document/document-render-libreoffice/package.json +++ b/packages/document/document-convert-libreoffice/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-document-render-libreoffice", + "name": "@deepseek-ai/dsh-document-convert-libreoffice", "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", "version": "0.1.6-alpha.1", "publishConfig": { @@ -12,7 +12,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-render-libreoffice" + "directory": "packages/document/document-convert-libreoffice" }, "exports": { ".": { @@ -28,11 +28,11 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^" + "@deepseek-ai/dsh-document-convert": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/dsh-document-convert": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^" }, diff --git a/packages/document/document-render-libreoffice/src/index.ts b/packages/document/document-convert-libreoffice/src/index.ts similarity index 84% rename from packages/document/document-render-libreoffice/src/index.ts rename to packages/document/document-convert-libreoffice/src/index.ts index 21296d11f0..3f4ef308c2 100644 --- a/packages/document/document-render-libreoffice/src/index.ts +++ b/packages/document/document-convert-libreoffice/src/index.ts @@ -5,10 +5,10 @@ import { tmpdir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' -import { DocumentRenderer, DocumentRenderError, DocumentRendererGeneration, type DocumentExtension, type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import { DocumentConverter, DocumentConvertError, DocumentConverterGeneration, type DocumentExtension, type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import z from '@deepseek-ai/schemastery' import { readPdf } from './output.ts' -import { RenderQueue } from './queue.ts' +import { ConversionQueue } from './queue.ts' /** Provider concurrency and kit rendering/font configuration. */ export interface Config { @@ -81,11 +81,11 @@ interface Slot { } /** A provider lifetime owns all converters, queued calls, and temporary files. */ -export class LibreOfficeRenderer extends DocumentRenderer { +export class LibreOfficeConverter extends DocumentConverter { static Config = Config - readonly generation = DocumentRendererGeneration(randomUUID()) + readonly generation = DocumentConverterGeneration(randomUUID()) private readonly slots: Slot[] = [] - private readonly queue: RenderQueue + private readonly queue: ConversionQueue private readonly options: ConverterOptions /** @@ -103,7 +103,7 @@ export class LibreOfficeRenderer extends DocumentRenderer { ...(fontDirectories === undefined ? {} : { fontDirectories }), ...(fontFallbacks === undefined ? {} : { fontFallbacks }), } - this.queue = new RenderQueue(config, this.generation, (bytes, extension, signal) => this.convert(bytes, extension, signal)) + this.queue = new ConversionQueue(config, this.generation, (bytes, extension, signal) => this.convertBytes(bytes, extension, signal)) ctx.effect(() => async () => { await this.queue.dispose() const results = await Promise.allSettled(this.slots.map(async (slot) => { @@ -115,11 +115,11 @@ export class LibreOfficeRenderer extends DocumentRenderer { }) } - render(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise { return this.queue.read(request, signal) } - private async convert(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + private async convertBytes(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { signal.throwIfAborted() let slot = this.slots.find(candidate => !candidate.busy) if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } @@ -134,7 +134,7 @@ export class LibreOfficeRenderer extends DocumentRenderer { } const converter = await slot.converter signal.throwIfAborted() - directory = await mkdtemp(join(tmpdir(), 'dsh-document-render-')) + directory = await mkdtemp(join(tmpdir(), 'dsh-document-convert-')) const inputPath = join(directory, `source.${extension}`) const outputPath = join(directory, 'converted.pdf') await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) @@ -144,20 +144,20 @@ export class LibreOfficeRenderer extends DocumentRenderer { let pdf: Uint8Array try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } catch (cause) { - if (cause instanceof DocumentRenderError) throw cause - throw new DocumentRenderError('invalid-output', 'The converter PDF could not be read.', { cause }) + if (cause instanceof DocumentConvertError) throw cause + throw new DocumentConvertError('invalid-output', 'The converter PDF could not be read.', { cause }) } signal.throwIfAborted() return { pdf, missingFonts: result.missingFonts } } catch (cause) { signal.throwIfAborted() - if (cause instanceof DocumentRenderError) throw cause + if (cause instanceof DocumentConvertError) throw cause const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined switch (code) { case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': case 'invalid-output': case 'timeout': case 'unavailable': - throw new DocumentRenderError(code, 'LibreOffice conversion failed.', { cause }) - default: throw new DocumentRenderError('failed', 'LibreOffice conversion failed.', { cause }) + throw new DocumentConvertError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new DocumentConvertError('failed', 'LibreOffice conversion failed.', { cause }) } } finally { try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } @@ -166,4 +166,4 @@ export class LibreOfficeRenderer extends DocumentRenderer { } } -export default LibreOfficeRenderer +export default LibreOfficeConverter diff --git a/packages/document/document-render-libreoffice/src/output.ts b/packages/document/document-convert-libreoffice/src/output.ts similarity index 69% rename from packages/document/document-render-libreoffice/src/output.ts rename to packages/document/document-convert-libreoffice/src/output.ts index 863c681020..41dee85ee2 100644 --- a/packages/document/document-render-libreoffice/src/output.ts +++ b/packages/document/document-convert-libreoffice/src/output.ts @@ -1,7 +1,7 @@ /** Read only the bounded, regular PDF created inside a private task directory. */ import { constants } from 'node:fs' import { lstat, open } from 'node:fs/promises' -import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' +import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' /** * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. @@ -13,12 +13,12 @@ import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { signal.throwIfAborted() const entry = await lstat(path) - if (!entry.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + if (!entry.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await file.stat() - if (!info.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') - if (info.size > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (!info.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const bytes = Buffer.alloc(info.size + 1) let length = 0 while (length < bytes.length) { @@ -28,11 +28,11 @@ export async function readPdf(path: string, limit: number, signal: AbortSignal): length += read.bytesRead } signal.throwIfAborted() - if (length > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (length > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const pdf = bytes.subarray(0, length) if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { - throw new DocumentRenderError('invalid-output', 'The converter did not produce a complete PDF.') + throw new DocumentConvertError('invalid-output', 'The converter did not produce a complete PDF.') } return pdf } finally { await file.close() } diff --git a/packages/document/document-render-libreoffice/src/queue.ts b/packages/document/document-convert-libreoffice/src/queue.ts similarity index 82% rename from packages/document/document-render-libreoffice/src/queue.ts rename to packages/document/document-convert-libreoffice/src/queue.ts index affcebfb3f..6ed9b0f487 100644 --- a/packages/document/document-render-libreoffice/src/queue.ts +++ b/packages/document/document-convert-libreoffice/src/queue.ts @@ -1,34 +1,34 @@ /** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ import { createHash } from 'node:crypto' -import { DocumentRenderError, DocumentRenderKey, type DocumentExtension, type DocumentRendererGeneration, - type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import { DocumentConvertError, DocumentConvertKey, type DocumentExtension, type DocumentConverterGeneration, + type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import type { Config } from './index.ts' -type Converted = Pick +type Converted = Pick type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise interface Reader { job: Job readonly source: string - readonly priority: DocumentRenderRequest['priority'] - readonly resolve: (value: DocumentRenderResult) => void + readonly priority: DocumentConvertRequest['priority'] + readonly resolve: (value: DocumentConvertResult) => void readonly reject: (error: unknown) => void readonly cleanup: () => void } interface Job { - readonly request: DocumentRenderRequest + readonly request: DocumentConvertRequest readonly controller: AbortController readonly readers: Set readonly sources: Set - priority: DocumentRenderRequest['priority'] + priority: DocumentConvertRequest['priority'] state: 'queued' | 'running' | 'finished' } -/** One renderer generation owns every queued source, conversion, reader, and retained PDF. */ -export class RenderQueue { - private readonly ready = new Map() - private readonly aliases = new Map() +/** One converter generation owns every queued source, conversion, reader, and retained PDF. */ +export class ConversionQueue { + private readonly ready = new Map() + private readonly aliases = new Map() private readonly sources = new Map() - private readonly digests = new Map() + private readonly digests = new Map() private readonly queue: Job[] = [] private readonly tasks = new Set>() private readonly jobs = new Set() @@ -44,7 +44,7 @@ export class RenderQueue { * @param generation - provider lifetime; prevents reuse after engine or font replacement. * @param convert - executes one admitted conversion and settles after scratch cleanup. */ - constructor(private readonly config: Config, private readonly generation: DocumentRendererGeneration, + constructor(private readonly config: Config, private readonly generation: DocumentConverterGeneration, private readonly convert: Convert) {} /** @@ -53,11 +53,11 @@ export class RenderQueue { * @param signal - this reader's cancellation; the final reader cancels shared work. * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. */ - async read(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + async read(request: DocumentConvertRequest, signal?: AbortSignal): Promise { signal?.throwIfAborted() if (this.disposed) throw this.unavailable() if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { - throw new DocumentRenderError('input-too-large', 'The Office source exceeds maxInputBytes.') + throw new DocumentConvertError('input-too-large', 'The Office source exceeds maxInputBytes.') } const source = JSON.stringify([request.source.key, request.source.version, request.extension]) const alias = this.aliases.get(source) @@ -87,7 +87,7 @@ export class RenderQueue { const shared = job if (request.priority === 'foreground') shared.priority = 'foreground' this.readers++ - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const abort = (): void => { this.release(reader) const reason: unknown = signal?.reason @@ -114,9 +114,9 @@ export class RenderQueue { await Promise.allSettled(this.tasks) } - private busy(): DocumentRenderError { return new DocumentRenderError('busy', 'The document renderer has reached its admission limit.') } - private unavailable(): DocumentRenderError { return new DocumentRenderError('unavailable', 'The document renderer is unavailable.') } - private copy(result: DocumentRenderResult): DocumentRenderResult { + private busy(): DocumentConvertError { return new DocumentConvertError('busy', 'The document converter has reached its admission limit.') } + private unavailable(): DocumentConvertError { return new DocumentConvertError('unavailable', 'The document converter is unavailable.') } + private copy(result: DocumentConvertResult): DocumentConvertResult { return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } } private release(reader: Reader): void { @@ -177,10 +177,10 @@ export class RenderQueue { signal.throwIfAborted() const input = await job.request.source.read(signal, reserved) signal.throwIfAborted() - if (input.version !== job.request.source.version) throw new DocumentRenderError('source-changed', 'The source changed while waiting for conversion.') - if (input.bytes.byteLength > reserved) throw new DocumentRenderError('input-too-large', 'The source exceeds its reserved read capacity.') + if (input.version !== job.request.source.version) throw new DocumentConvertError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new DocumentConvertError('input-too-large', 'The source exceeds its reserved read capacity.') const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') - const key = DocumentRenderKey(`${this.generation}:${digest}`) + const key = DocumentConvertKey(`${this.generation}:${digest}`) const cached = this.ready.get(key) if (cached !== undefined) { this.ready.delete(key) @@ -207,7 +207,7 @@ export class RenderQueue { } finally { if (this.digests.get(key) === job) this.digests.delete(key) } } - private finish(job: Job, result: DocumentRenderResult): void { + private finish(job: Job, result: DocumentConvertResult): void { for (const source of job.sources) this.sources.delete(source) if (this.ready.has(result.cacheKey)) { for (const source of job.sources) { @@ -218,10 +218,10 @@ export class RenderQueue { } for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } } - private retain(result: DocumentRenderResult): void { + private retain(result: DocumentConvertResult): void { if (result.pdf.byteLength > this.config.maxCachedBytes) return while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { - const [key, oldest] = this.ready.entries().next().value as [DocumentRenderKey, DocumentRenderResult] + const [key, oldest] = this.ready.entries().next().value as [DocumentConvertKey, DocumentConvertResult] this.ready.delete(key) this.cachedBytes -= oldest.pdf.byteLength for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) diff --git a/packages/document/document-render-libreoffice/tests/output.spec.ts b/packages/document/document-convert-libreoffice/tests/output.spec.ts similarity index 100% rename from packages/document/document-render-libreoffice/tests/output.spec.ts rename to packages/document/document-convert-libreoffice/tests/output.spec.ts diff --git a/packages/document/document-render-libreoffice/tests/provider.spec.ts b/packages/document/document-convert-libreoffice/tests/provider.spec.ts similarity index 80% rename from packages/document/document-render-libreoffice/tests/provider.spec.ts rename to packages/document/document-convert-libreoffice/tests/provider.spec.ts index aa36e95d2c..d84e459ced 100644 --- a/packages/document/document-render-libreoffice/tests/provider.spec.ts +++ b/packages/document/document-convert-libreoffice/tests/provider.spec.ts @@ -1,22 +1,22 @@ /** Disk output, resource bounds, and cancellation around the external kit. */ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { Context } from '@deepseek-ai/cordis' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import LibreOfficeRenderer, { Config } from '../src/index.ts' +import LibreOfficeConverter, { Config } from '../src/index.ts' const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') const input = new Uint8Array([80, 75, 3, 4]) -const request: DocumentRenderRequest = { extension: 'docx', priority: 'foreground', source: { +const request: DocumentConvertRequest = { extension: 'docx', priority: 'foreground', source: { key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, read: async () => ({ bytes: input, version: 'v1' }), } } -function distinct(index: number): DocumentRenderRequest { +function distinct(index: number): DocumentConvertRequest { return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } } @@ -35,14 +35,14 @@ beforeEach(() => { }) afterEach(async () => { await ctx.fiber.dispose() }) -async function mount(config: Partial = {}): Promise { - await ctx.plugin(LibreOfficeRenderer, config) - return ctx.documentRender as LibreOfficeRenderer +async function mount(config: Partial = {}): Promise { + await ctx.plugin(LibreOfficeConverter, config) + return ctx.documentConvert as LibreOfficeConverter } it('preserves the kit font defaults when font configuration is omitted', async () => { - const renderer = await mount() - await renderer.render(request) + const provider = await mount() + await provider.convert(request) expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontDirectories') expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontFallbacks') expect(Config({ fontDirectories: [] }).fontDirectories).toEqual([]) @@ -65,7 +65,7 @@ it('passes configured limits to the kit, reuses the converter, and removes all s maxImageResolution: 144, maxArchiveEntries: 32, maxUncompressedBytes: 4096, timeoutMs: 321, fontDirectories: [], fontFallbacks: [['sans-serif', 'Arial'], ['serif', 'Times New Roman']], maxFontFiles: 12, maxFontFileBytes: 4096, maxLoadedFontBytes: 8192 }) - const renderer = await mount(config) + const provider = await mount(config) let scratch = '' render.mockImplementation(async ({ inputPath, outputPath }) => { scratch = dirname(inputPath) @@ -77,10 +77,10 @@ it('passes configured limits to the kit, reuses the converter, and removes all s await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: ['Missing Serif'] } }) - const result = await renderer.render(request) + const result = await provider.convert(request) expect(result).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) - await renderer.render(request) + await provider.convert(request) const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source, maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached, maxSourceEntries: _aliases, ...options } = config @@ -91,13 +91,13 @@ it('passes configured limits to the kit, reuses the converter, and removes all s }) it('refuses an oversized input before allocating a converter', async () => { - const renderer = await mount({ maxInputBytes: 3 }) - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'input-too-large' }) + const provider = await mount({ maxInputBytes: 3 }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'input-too-large' }) expect(kit.create).not.toHaveBeenCalled() }) it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const)('rejects %s kit output and removes its directory', async (kind) => { - const renderer = await mount({ maxOutputBytes: pdf.length }) + const provider = await mount({ maxOutputBytes: pdf.length }) let scratch = '' render.mockImplementation(async ({ inputPath, outputPath }) => { scratch = dirname(inputPath) @@ -107,31 +107,31 @@ it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const) else if (kind === 'too-large') await writeFile(outputPath, Buffer.concat([pdf, pdf])) return { backend: 'wasm', missingFonts: [] } }) - await expect(renderer.render(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('retries initialization after a failed kit factory and reports unclassified errors', async () => { - const renderer = await mount() + const provider = await mount() kit.create.mockRejectedValueOnce(Object.assign(new Error('absent asset'), { code: 'unavailable' })) - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) - expect(await renderer.render(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'unavailable' }) + expect(await provider.convert(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) expect(kit.create).toHaveBeenCalledTimes(2) render.mockRejectedValueOnce(new Error('unexpected engine failure')) - await expect(renderer.render(distinct(9))).rejects.toMatchObject({ code: 'failed' }) + await expect(provider.convert(distinct(9))).rejects.toMatchObject({ code: 'failed' }) }) it.each([ 'input-too-large', 'output-too-large', 'invalid-document', 'unsupported-format', 'invalid-output', 'timeout', 'unavailable', ] as const)('preserves the kit %s failure for the document consumer', async (code) => { - const renderer = await mount() + const provider = await mount() const cause = Object.assign(new Error('conversion failed'), { code }) render.mockRejectedValueOnce(cause) - await expect(renderer.render(request)).rejects.toMatchObject({ code, cause }) + await expect(provider.convert(request)).rejects.toMatchObject({ code, cause }) }) it('cancels a queued caller without starting or stopping another conversion', async () => { - const renderer = await mount({ maxConcurrentConversions: 1 }) + const provider = await mount({ maxConcurrentConversions: 1 }) const entered = Promise.withResolvers() const release = Promise.withResolvers() render.mockImplementationOnce(async ({ outputPath }, signal) => { @@ -140,11 +140,11 @@ it('cancels a queued caller without starting or stopping another conversion', as await writeFile(outputPath, pdf) return { backend: 'wasm', missingFonts: [] } }) - const first = renderer.render(request) + const first = provider.convert(request) try { const activeSignal = await entered.promise const controller = new AbortController() - const second = renderer.render(request, controller.signal) + const second = provider.convert(request, controller.signal) const rejected = expect(second).rejects.toMatchObject({ name: 'AbortError' }) await Promise.resolve(undefined) controller.abort() @@ -152,11 +152,11 @@ it('cancels a queued caller without starting or stopping another conversion', as expect(render).toHaveBeenCalledOnce() expect(activeSignal.aborted).toBe(false) } finally { release.resolve(undefined); await first } - expect(await renderer.render(request)).toHaveProperty('pdf') + expect(await provider.convert(request)).toHaveProperty('pdf') }) it('bounds active converters and resumes queued work when a slot becomes free', async () => { - const renderer = await mount({ maxConcurrentConversions: 2 }) + const provider = await mount({ maxConcurrentConversions: 2 }) const both = Promise.withResolvers() const release = Promise.withResolvers() let calls = 0 @@ -166,7 +166,7 @@ it('bounds active converters and resumes queued work when a slot becomes free', await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: [] } }) - const work = [renderer.render(distinct(1)), renderer.render(distinct(2)), renderer.render(distinct(3))] + const work = [provider.convert(distinct(1)), provider.convert(distinct(2)), provider.convert(distinct(3))] try { await both.promise expect(kit.create).toHaveBeenCalledTimes(2) @@ -177,7 +177,7 @@ it('bounds active converters and resumes queued work when a slot becomes free', }) it.each(['caller', 'provider'] as const)('joins late kit completion and scratch cleanup after %s cancellation', async (owner) => { - const renderer = await mount() + const provider = await mount() const entered = Promise.withResolvers<{ signal: AbortSignal; scratch: string }>() const release = Promise.withResolvers() render.mockImplementation(async ({ inputPath, outputPath }, signal) => { @@ -187,7 +187,7 @@ it.each(['caller', 'provider'] as const)('joins late kit completion and scratch return { backend: 'native', missingFonts: [] } }) const caller = new AbortController() - const work = renderer.render(request, caller.signal) + const work = provider.convert(request, caller.signal) const rejected = expect(work).rejects.toMatchObject(owner === 'caller' ? { name: 'AbortError' } : { code: 'unavailable' }) const { signal, scratch } = await entered.promise let settled = false @@ -207,11 +207,11 @@ it.each(['caller', 'provider'] as const)('joins late kit completion and scratch }) it('joins initialization during disposal and never starts a render after cancellation', async () => { - const renderer = await mount() + const provider = await mount() const entered = Promise.withResolvers() const release = Promise.withResolvers() kit.create.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise }) - const work = renderer.render(request) + const work = provider.convert(request) const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' }) await entered.promise const closing = ctx.fiber.dispose() @@ -220,29 +220,29 @@ it('joins initialization during disposal and never starts a render after cancell await closing expect(render).not.toHaveBeenCalled() expect(dispose).toHaveBeenCalledOnce() - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'unavailable' }) }) it('rejects caller cancellation before allocating any conversion resources', async () => { - const renderer = await mount() + const provider = await mount() const reason = new Error('caller cancelled') - await expect(renderer.render(request, AbortSignal.abort(reason))).rejects.toBe(reason) + await expect(provider.convert(request, AbortSignal.abort(reason))).rejects.toBe(reason) expect(kit.create).not.toHaveBeenCalled() }) it('rejects relative font directories during provider configuration', async () => { - expect(() => new LibreOfficeRenderer(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + expect(() => new LibreOfficeConverter(ctx, Config({ fontDirectories: ['relative/fonts'] }))) .toThrow('fontDirectories must contain absolute paths') }) it('rejects source capacity below one permitted input before allocating a converter', () => { - expect(() => new LibreOfficeRenderer(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + expect(() => new LibreOfficeConverter(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) .toThrow('maxSourceBytes must be at least maxInputBytes') expect(kit.create).not.toHaveBeenCalled() }) it('joins every converter disposal before reporting an engine cleanup failure', async () => { - const renderer = await mount({ maxConcurrentConversions: 2 }) + const provider = await mount({ maxConcurrentConversions: 2 }) const entered = Promise.withResolvers() const released = Promise.withResolvers() const cleanupEntered = Promise.withResolvers() @@ -258,7 +258,7 @@ it('joins every converter disposal before reporting an engine cleanup failure', kit.create.mockReset() .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } }) .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) - const work = [renderer.render(distinct(1)), renderer.render(distinct(2))] + const work = [provider.convert(distinct(1)), provider.convert(distinct(2))] try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) } let disposed = false const closing = ctx.fiber.dispose().then(() => { disposed = true }) diff --git a/packages/document/document-render-libreoffice/tests/queue.spec.ts b/packages/document/document-convert-libreoffice/tests/queue.spec.ts similarity index 96% rename from packages/document/document-render-libreoffice/tests/queue.spec.ts rename to packages/document/document-convert-libreoffice/tests/queue.spec.ts index 9ac47223d5..ced4a3a646 100644 --- a/packages/document/document-render-libreoffice/tests/queue.spec.ts +++ b/packages/document/document-convert-libreoffice/tests/queue.spec.ts @@ -1,18 +1,18 @@ /** Controlled source and engine completions exercise admission and shared content ownership. */ -import { DocumentSourceKey, DocumentRendererGeneration, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { DocumentSourceKey, DocumentConverterGeneration, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { expect, it, onTestFinished, vi } from 'vitest' -import { RenderQueue } from '../src/queue.ts' +import { ConversionQueue } from '../src/queue.ts' import { Config } from '../src/index.ts' const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } -function source(key: string, byte = 1, priority: DocumentRenderRequest['priority'] = 'foreground') { - const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) - const request: DocumentRenderRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } +function source(key: string, byte = 1, priority: DocumentConvertRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: DocumentConvertRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } return { read, request } } function harness(config: Partial = {}) { - const convert = vi.fn[2]>().mockResolvedValue(output) - const queue = new RenderQueue(Config(config), DocumentRendererGeneration('test'), convert) + const convert = vi.fn[2]>().mockResolvedValue(output) + const queue = new ConversionQueue(Config(config), DocumentConverterGeneration('test'), convert) onTestFinished(() => queue.dispose()) return { queue, convert } } @@ -290,7 +290,7 @@ it('keeps a synchronous abort replacement shareable after the old conversion set const h = harness({ maxConcurrentConversions: 2 }) const firstEntered = Promise.withResolvers(), firstComplete = Promise.withResolvers() const replacementEntered = Promise.withResolvers(), replacementComplete = Promise.withResolvers() - const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() + const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() const a = source('a'), caller = new AbortController() const pending: Promise[] = [] h.convert.mockImplementationOnce((_bytes, _extension, signal) => { @@ -332,11 +332,11 @@ it('keeps a synchronous abort replacement shareable after the old conversion set } }) -it('separates Office extensions and renderer generations in content identity', async () => { +it('separates Office extensions and converter generations in content identity', async () => { const h = harness(), a = source('a') const first = await h.queue.read(a.request) const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) - const other = new RenderQueue(Config({}), DocumentRendererGeneration('replacement'), h.convert) + const other = new ConversionQueue(Config({}), DocumentConverterGeneration('replacement'), h.convert) onTestFinished(() => other.dispose()) const replacement = await other.read(a.request) expect(first.cacheKey).not.toBe(otherFormat.cacheKey) diff --git a/packages/document/document-render-libreoffice/tsconfig.json b/packages/document/document-convert-libreoffice/tsconfig.json similarity index 89% rename from packages/document/document-render-libreoffice/tsconfig.json rename to packages/document/document-convert-libreoffice/tsconfig.json index 8e81caaa59..2199a71798 100644 --- a/packages/document/document-render-libreoffice/tsconfig.json +++ b/packages/document/document-convert-libreoffice/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../document-render" + "path": "../document-convert" } ] } diff --git a/packages/document/document-render/README.i18n.yaml b/packages/document/document-convert/README.i18n.yaml similarity index 69% rename from packages/document/document-render/README.i18n.yaml rename to packages/document/document-convert/README.i18n.yaml index 1fff641553..b0aec675b0 100644 --- a/packages/document/document-render/README.i18n.yaml +++ b/packages/document/document-convert/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/document/document-render/README.md -README.md: ead6c62b25dcca326ad362af4e28a6431472f55c -README.zh.md: 45138e306a367833e0acade97b7d13f46fdcefa8 +# pnpm run verify-translation-pairing --write packages/document/document-convert/README.md +README.md: fbc98feef30c4e32c255b82f57ea1aad5a68940d +README.zh.md: 3fa18652036d4823d45e3ccc38d03f599b3c4326 diff --git a/packages/document/document-render/README.md b/packages/document/document-convert/README.md similarity index 69% rename from packages/document/document-render/README.md rename to packages/document/document-convert/README.md index ead6c62b25..fbc98feef3 100644 --- a/packages/document/document-render/README.md +++ b/packages/document/document-convert/README.md @@ -3,7 +3,7 @@ description: "Authorized Office input and complete PDF output for Host conversio kind: "package-reference" --- -# @deepseek-ai/dsh-document-render +# @deepseek-ai/dsh-document-convert English | [中文](README.zh.md) @@ -25,7 +25,7 @@ Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes t ## Use this package -Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to supply `ctx.documentRender`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `render()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. +Mount the [LibreOffice provider](../document-convert-libreoffice/README.md) to supply `ctx.documentConvert`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `convert()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. ----- @@ -35,7 +35,7 @@ Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to su
Implementation internals — click to expand -Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentRenderError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentRenderError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. +Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentConvertError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentConvertError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation.
@@ -44,7 +44,7 @@ Providers return independent caller-owned PDF bytes, a conversion cache key, and ## Further Exploration -- [Document rendering](../../../docs/subsystems/document-render.md) — composition and ownership. +- [Document conversion](../../../docs/subsystems/document-convert.md) — composition and ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-render/README.zh.md b/packages/document/document-convert/README.zh.md similarity index 67% rename from packages/document/document-render/README.zh.md rename to packages/document/document-convert/README.zh.md index 45138e306a..3fa1865203 100644 --- a/packages/document/document-render/README.zh.md +++ b/packages/document/document-convert/README.zh.md @@ -3,7 +3,7 @@ description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF kind: "package-reference" --- -# @deepseek-ai/dsh-document-render +# @deepseek-ai/dsh-document-convert [English](README.md) | 中文 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -挂载 [LibreOffice 提供方](../document-render-libreoffice/README.zh.md)以提供 `ctx.documentRender`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `render()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 +挂载 [LibreOffice 提供方](../document-convert-libreoffice/README.zh.md)以提供 `ctx.documentConvert`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `convert()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 ----- @@ -35,7 +35,7 @@ kind: "package-reference"
实现内部细节 — 点击展开 -提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和渲染 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentRenderError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentRenderError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 +提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和转换 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentConvertError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentConvertError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。
@@ -44,7 +44,7 @@ kind: "package-reference" ## 延伸阅读 -- [文档渲染](../../../docs/subsystems/document-render.zh.md) — 组合和所有权。 +- [文档转换](../../../docs/subsystems/document-convert.zh.md) — 组合和所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-render/package.json b/packages/document/document-convert/package.json similarity index 90% rename from packages/document/document-render/package.json rename to packages/document/document-convert/package.json index 741ffca7db..bb7805f3cc 100644 --- a/packages/document/document-render/package.json +++ b/packages/document/document-convert/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-document-render", + "name": "@deepseek-ai/dsh-document-convert", "description": "Authorized Office bytes to PDF rendering service", "version": "0.1.6-alpha.1", "publishConfig": { @@ -12,7 +12,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-render" + "directory": "packages/document/document-convert" }, "exports": { ".": { diff --git a/packages/document/document-convert/src/index.ts b/packages/document/document-convert/src/index.ts new file mode 100644 index 0000000000..014e71c280 --- /dev/null +++ b/packages/document/document-convert/src/index.ts @@ -0,0 +1,44 @@ +/** Host document conversion: authorized Office bytes become a complete PDF. */ +import { Context, Service } from '@deepseek-ai/cordis' +import type { DocumentConvertErrorCode, DocumentConvertRequest, DocumentConvertResult, DocumentConverterGeneration } from './types.ts' + +export * from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class DocumentConvertError 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: DocumentConvertErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DocumentConvertError' + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** One Host converter for already-authorized Office bytes. */ + documentConvert: DocumentConverter + } +} + +/** Load one provider subclass per context; consumers own source authorization. */ +export abstract class DocumentConverter extends Service { + constructor(ctx: Context) { super(ctx, 'documentConvert') } + + /** Changes whenever engine, font, or conversion configuration is replaced. */ + abstract readonly generation: DocumentConverterGeneration + + /** + * Convert Office bytes without modifying the source or writing Session events. + * @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 {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ + abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +} + +export default DocumentConverter diff --git a/packages/document/document-render/src/types.ts b/packages/document/document-convert/src/types.ts similarity index 77% rename from packages/document/document-render/src/types.ts rename to packages/document/document-convert/src/types.ts index ce23ae3cb6..35f63911ac 100644 --- a/packages/document/document-render/src/types.ts +++ b/packages/document/document-convert/src/types.ts @@ -14,32 +14,32 @@ export type DocumentSourceKey = Branded<'DocumentSourceKey'> export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } /** One provider lifetime, including its engine, rendering settings, and font configuration. */ -export type DocumentRendererGeneration = Branded<'DocumentRendererGeneration'> +export type DocumentConverterGeneration = Branded<'DocumentConverterGeneration'> /** * Label a provider lifetime. * @param value - unique generation created by the provider. - * @returns branded renderer generation. + * @returns branded converter generation. */ -export function DocumentRendererGeneration(value: string): DocumentRendererGeneration { - return brandString(value) +export function DocumentConverterGeneration(value: string): DocumentConverterGeneration { + return brandString(value) } /** Provider generation and source-content digest; consumers must not parse it. */ -export type DocumentRenderKey = Branded<'DocumentRenderKey'> +export type DocumentConvertKey = Branded<'DocumentConvertKey'> /** - * Label a renderer-owned content identity. + * Label a converter-owned content identity. * @param value - generation and content identity created by the provider. * @returns branded conversion identity. */ -export function DocumentRenderKey(value: string): DocumentRenderKey { return brandString(value) } +export function DocumentConvertKey(value: string): DocumentConvertKey { return brandString(value) } /** Foreground previews and explicit QA precede speculative background conversion. */ -export type DocumentRenderPriority = 'foreground' | 'background' +export type DocumentConvertPriority = 'foreground' | 'background' /** Source authorization and metadata lookup must finish before submitting a request. */ -export interface DocumentRenderRequest { +export interface DocumentConvertRequest { readonly extension: DocumentExtension - readonly priority: DocumentRenderPriority + readonly priority: DocumentConvertPriority readonly source: { readonly key: DocumentSourceKey readonly version: string @@ -56,16 +56,16 @@ export interface DocumentRenderRequest { } /** Successful conversion; failed and interrupted conversions reject instead. */ -export interface DocumentRenderResult { +export interface DocumentConvertResult { /** Caller-owned complete PDF, valid after provider disposal. */ readonly pdf: Uint8Array /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ readonly missingFonts: string[] - readonly cacheKey: DocumentRenderKey - readonly generation: DocumentRendererGeneration + readonly cacheKey: DocumentConvertKey + readonly generation: DocumentConverterGeneration } /** Failures a conversion consumer can present without exposing engine diagnostics. */ -export type DocumentRenderErrorCode = +export type DocumentConvertErrorCode = | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-render/tsconfig.json b/packages/document/document-convert/tsconfig.json similarity index 100% rename from packages/document/document-render/tsconfig.json rename to packages/document/document-convert/tsconfig.json diff --git a/packages/document/document-render/src/index.ts b/packages/document/document-render/src/index.ts deleted file mode 100644 index 49d121945e..0000000000 --- a/packages/document/document-render/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Host document rendering: authorized Office bytes become a complete PDF. */ -import { Context, Service } from '@deepseek-ai/cordis' -import type { DocumentRenderErrorCode, DocumentRenderRequest, DocumentRenderResult, DocumentRendererGeneration } from './types.ts' - -export * from './types.ts' - -/** Classified conversion failure; engine details stay in the cause. */ -export class DocumentRenderError 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: DocumentRenderErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'DocumentRenderError' - } -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** One Host renderer for already-authorized Office bytes. */ - documentRender: DocumentRenderer - } -} - -/** Load one provider subclass per context; consumers own source authorization. */ -export abstract class DocumentRenderer extends Service { - constructor(ctx: Context) { super(ctx, 'documentRender') } - - /** Changes whenever engine, font, or rendering configuration is replaced. */ - abstract readonly generation: DocumentRendererGeneration - - /** - * Convert Office bytes without modifying the source or writing Session events. - * @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 {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ - abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise -} - -export default DocumentRenderer diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts index a871627f38..7c76b2aea4 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -3,7 +3,7 @@ import { notAvailableError } from '../notImplementedFail.ts' /** * Refuse converter creation with the kit's unavailable error code. - * @returns A rejected promise consumed by the Host document-render provider. + * @returns A rejected promise consumed by the Host document-convert provider. */ export function createConverter(): Promise { return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 13943f8831..170862eb80 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -875,21 +875,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ ], }, { - key: 'documentRender', + key: 'documentConvert', summary: 'Load one provider subclass per context; consumers own source authorization.', description: 'Load one provider subclass per context; consumers own source authorization.', methods: [ { - signature: 'abstract readonly generation: DocumentRendererGeneration', - description: 'Changes whenever engine, font, or rendering configuration is replaced.', + signature: 'abstract readonly generation: DocumentConverterGeneration', + description: 'Changes whenever engine, font, or conversion configuration is replaced.', parameters: [], }, { - signature: 'abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise', + signature: 'abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise', description: 'Convert Office bytes without modifying the source or writing Session events.', parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', - throws: ['{DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], + throws: ['{DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], }, ], }, @@ -4442,30 +4442,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, + { + name: 'DocumentConverterGeneration', + declaration: 'export type DocumentConverterGeneration = Branded<\'DocumentConverterGeneration\'>;', + }, + { + name: 'DocumentConvertKey', + declaration: 'export type DocumentConvertKey = Branded<\'DocumentConvertKey\'>;', + }, + { + name: 'DocumentConvertPriority', + declaration: 'export type DocumentConvertPriority = \'foreground\' | \'background\';', + }, + { + name: 'DocumentConvertRequest', + declaration: 'export interface DocumentConvertRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentConvertPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'DocumentConvertResult', + declaration: 'export interface DocumentConvertResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentConvertKey;\n readonly generation: DocumentConverterGeneration;\n}', + }, { name: 'DocumentExtension', declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', }, - { - name: 'DocumentRendererGeneration', - declaration: 'export type DocumentRendererGeneration = Branded<\'DocumentRendererGeneration\'>;', - }, - { - name: 'DocumentRenderKey', - declaration: 'export type DocumentRenderKey = Branded<\'DocumentRenderKey\'>;', - }, - { - name: 'DocumentRenderPriority', - declaration: 'export type DocumentRenderPriority = \'foreground\' | \'background\';', - }, - { - name: 'DocumentRenderRequest', - declaration: 'export interface DocumentRenderRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentRenderPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', - }, - { - name: 'DocumentRenderResult', - declaration: 'export interface DocumentRenderResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentRenderKey;\n readonly generation: DocumentRendererGeneration;\n}', - }, { name: 'DocumentSourceKey', declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839bfa43b1..9a6bf55063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2199,12 +2199,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner - '@deepseek-ai/dsh-document-render': + '@deepseek-ai/dsh-document-convert': specifier: workspace:^ - version: link:../../document/document-render - '@deepseek-ai/dsh-document-render-libreoffice': + version: link:../../document/document-convert + '@deepseek-ai/dsh-document-convert-libreoffice': specifier: workspace:^ - version: link:../../document/document-render-libreoffice + version: link:../../document/document-convert-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -5805,7 +5805,7 @@ importers: specifier: workspace:^ version: link:../../util/launch-environment - packages/document/document-render: + packages/document/document-convert: dependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5815,7 +5815,7 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis - packages/document/document-render-libreoffice: + packages/document/document-convert-libreoffice: dependencies: '@deepseek-ai/libreoffice-kit': specifier: 0.0.1 @@ -5833,9 +5833,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-document-render': + '@deepseek-ai/dsh-document-convert': specifier: workspace:^ - version: link:../document-render + version: link:../document-convert packages/experimental/agent-team: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 809a1b49d2..7e94b1b937 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,7 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', - documentRender: 'document-render.md', + documentConvert: 'document-convert.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -274,10 +274,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', - DocumentRenderRequest: 'document-render.md', - DocumentRenderResult: 'document-render.md', - DocumentRenderPriority: 'document-render.md', - DocumentRendererGeneration: 'document-render.md', + DocumentConvertRequest: 'document-convert.md', + DocumentConvertResult: 'document-convert.md', + DocumentConvertPriority: 'document-convert.md', + DocumentConverterGeneration: 'document-convert.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 4878686426..e9bc5c90c0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -151,8 +151,8 @@ const SERVICE_ROLES: ServiceRole[] = [ note: '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.', }, { - key: 'documentRender', pkg: 'document-render', title: 'Office to PDF rendering', - mode: 'seam', implementations: ['document-render-libreoffice'], consumers: [], + key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', + mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 90e9039d96..b19067e64b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,8 +53,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, - 'packages/document/document-render': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, - 'packages/document/document-render-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, + 'packages/document/document-convert': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, + 'packages/document/document-convert-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ef3f69896f..b8c8eff372 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": ["./packages/experimental/browser-use-stagehand-native/src/index.ts"], "@deepseek-ai/dsh-experimental-browser-use-runtime/mcp": ["./packages/experimental/browser-use-runtime/src/mcp.ts"], + "@deepseek-ai/dsh-document-convert/types": ["./packages/document/document-convert/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], @@ -324,8 +325,8 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], - "@deepseek-ai/dsh-document-render": ["./packages/document/document-render/src"], - "@deepseek-ai/dsh-document-render-libreoffice": ["./packages/document/document-render-libreoffice/src"], + "@deepseek-ai/dsh-document-convert": ["./packages/document/document-convert/src"], + "@deepseek-ai/dsh-document-convert-libreoffice": ["./packages/document/document-convert-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 4948cf5195..d70b0d03e8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -158,8 +158,8 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, - { "path": "./packages/document/document-render" }, - { "path": "./packages/document/document-render-libreoffice" }, + { "path": "./packages/document/document-convert" }, + { "path": "./packages/document/document-convert-libreoffice" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From fcf9f4cfa274e830808d4748a688e883eb880174 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 14:55:38 +0800 Subject: [PATCH 03/18] fix(document): select Office engines from declared native targets --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +-- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...26-09-15-platform-office-engines.i18n.yaml | 4 +-- .../2026-09-15-platform-office-engines.md | 10 +++---- .../2026-09-15-platform-office-engines.zh.md | 10 +++---- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/prepare-dsh.ts | 6 ++-- apps/desktop/scripts/runtime-file-policy.ts | 6 ++-- .../desktop/tests/runtime-file-policy.spec.ts | 29 ++++++++++++++----- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/subsystems/document-convert.i18n.yaml | 4 +-- docs/subsystems/document-convert.md | 2 +- docs/subsystems/document-convert.zh.md | 2 +- packages/document/README.i18n.yaml | 4 +-- packages/document/README.md | 2 +- packages/document/README.zh.md | 2 +- .../README.i18n.yaml | 4 +-- .../document-convert-libreoffice/README.md | 4 +-- .../document-convert-libreoffice/README.zh.md | 4 +-- .../document-convert-libreoffice/package.json | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 13 ++++++--- .../src/deepseek_harness_runtime/__init__.py | 22 +++++++++----- python/sdk/tests/test_release_version.py | 5 +++- python/sdk/tests/test_runtime_resolution.py | 24 +++++++++++---- .../build-exe-for-python-sdk-office.spec.ts | 22 ++++++++------ scripts/build-exe-for-python-sdk-office.ts | 11 ++++--- scripts/build-python-release.py | 4 ++- scripts/gen-doc-graphs.ts | 2 +- scripts/libreoffice-engine.spec.ts | 24 +++++++++++++++ scripts/libreoffice-engine.ts | 17 +++++++++++ scripts/smoke-python-runtime.py | 6 +++- 39 files changed, 187 insertions(+), 90 deletions(-) create mode 100644 scripts/libreoffice-engine.spec.ts create mode 100644 scripts/libreoffice-engine.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 2550d722da..d12179dde4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 45191be901f8df048d48798158c83fdfd8134737 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: b76035e6c81841e5eda4dcbcd575479996eff4b9 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 45191be901..56c9770966 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -72,7 +72,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. -The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (WASM on Linux, native on macOS/Windows), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. +The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index b76035e6c8..04351105a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -72,7 +72,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 -安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(Linux 为 WASM,macOS/Windows 为原生),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 +安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml index 8fd429f5bf..e10cadf677 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md -2026-09-15-platform-office-engines.md: fa8656eb7505b599f76640077f9b957fd867269c -2026-09-15-platform-office-engines.zh.md: eed1879b9a0bd5fe5056670c414c4db4b81d9d9b +2026-09-15-platform-office-engines.md: a3d1365bf33006c31d53c54f1255f72c4663cb5d +2026-09-15-platform-office-engines.zh.md: dfffd1b9de33a2b138e0450f8a60d940fadcefd4 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md index fa8656eb75..a3d1365bf3 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -10,20 +10,20 @@ Installing WASM beside a usable native engine adds a second LibreOffice payload ## Decision -Harness depends on the kit API and uses its platform-filtered optional engine dependencies. macOS and Windows install their matching ARM64 or x64 native package; Linux installs the shared WASM package. WASM declares Linux as its npm OS. The provider has no direct WASM dependency. Missing native engines on macOS/Windows reject converter creation instead of selecting WASM. +Harness selects one engine from the installed kit API’s `optionalDependencies`. A declared `@deepseek-ai/libreoffice-kit-${platform}-${arch}` requires that native package; other targets require the shared WASM package. The supported native target set belongs to the kit release, not an operating-system branch in Harness. A missing declared native package is an incomplete installation and never selects WASM. The provider has no direct WASM dependency. -Python sidecar assembly copies only the target engine and its dependency closure. Wheel packaging and runtime lookup require that engine; the relocated conversion smoke checks the selected backend. Desktop carries the ordinary npm installation in application resources. Engine compilation, qualification, and publication remain in the kit repository. +Python sidecar assembly copies only the selected engine and its dependency closure. Wheel packaging and runtime lookup select from the same kit manifest; the relocated conversion smoke checks the selected backend. Desktop filters its npm installation to that engine before signing and integrity sealing. Engine compilation, resolver compatibility, npm platform metadata, qualification, and publication remain in the kit repository. ## Alternatives considered -**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every macOS/Windows installation. Fixed-platform distributions require their native engine to be present and tested instead. +**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every distribution with native support. Fixed-platform distributions require their declared native engine to be present and tested instead. -**Remove WASM on every platform.** Linux has no released native engine in this kit family. Keeping the shared WASM package on Linux preserves Office conversion without introducing a new native release target. +**Remove WASM on every platform.** Targets without a released native engine still need conversion. The shared WASM package serves those targets without introducing a new native release target. **Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost. ## Consequences -macOS/Windows distributions omit the WASM download and expanded assets. A missing native package becomes an installation defect that must be repaired; conversion does not download an engine. Linux retains its WASM resource and font requirements. The kit’s engine-selection tests and actual npm installation tests cover platform selection; Harness sidecar, wheel, and runtime-resolution tests cover packaging requirements. New package bytes require kit qualification and matching dependency integrity records before publication. +Distributions with a declared native target omit WASM assets. Other targets retain WASM resource and font requirements. The pinned kit declares macOS/Windows ARM64 and x64 native packages, so current Linux distributions select WASM; a kit release can add a native Linux target without changing Harness’s selection rule. This does not expand Harness’s supported release platforms. Harness sidecar, wheel, and runtime-resolution tests cover both declared native targets and WASM selection, including missing native packages. New package bytes require kit qualification and matching dependency integrity records before publication. The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md index eed1879b9a..dfffd1b9de 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -10,20 +10,20 @@ Status: implemented ## Decision -Harness 依赖 kit API,并使用它按平台筛选的可选引擎依赖。macOS 和 Windows 安装匹配的 ARM64 或 x64 原生包;Linux 安装共享 WASM 包。WASM 的 npm OS 声明为 Linux。provider 不直接依赖 WASM。macOS/Windows 缺少原生引擎时拒绝创建转换器,不会选择 WASM。 +Harness 从已安装 kit API 的 `optionalDependencies` 中选择一个引擎。声明了 `@deepseek-ai/libreoffice-kit-${platform}-${arch}` 的目标要求该原生包,其余目标要求共享 WASM 包。受支持的原生目标集合由 kit 发布版本决定,不由 Harness 中的操作系统分支决定。已声明的原生包缺失表示安装不完整,不会改选 WASM。provider 不直接依赖 WASM。 -Python sidecar 组装仅复制目标引擎及其依赖闭包。wheel 打包和运行时查找要求该引擎存在;迁移后的转换冒烟检查所选 backend。Desktop 在应用资源中携带常规 npm 安装结果。引擎编译、资格验证和发布仍由 kit 仓库负责。 +Python sidecar 组装仅复制所选引擎及其依赖闭包。wheel 打包和运行时查找依据同一份 kit 清单选择;迁移目录后的转换冒烟检查所选后端。Desktop 在签名和完整性封装前将 npm 安装结果过滤为该引擎。引擎编译、解析器兼容性、npm 平台元数据、资格验证与发布仍由 kit 仓库负责。 ## Alternatives considered -**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个 macOS/Windows 安装。固定平台分发物改为要求其原生引擎存在且经过测试。 +**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个支持原生引擎的分发物。固定平台分发物要求其已声明的原生引擎存在且经过测试。 -**在所有平台移除 WASM。** 当前 kit 家族没有已发布的 Linux 原生引擎。在 Linux 保留共享 WASM 包,既能继续转换 Office 文档,也无需引入新的原生发布目标。 +**在所有平台移除 WASM。** 没有已发布原生引擎的目标仍需转换能力。共享 WASM 包为这些目标提供转换,无需引入新的原生发布目标。 **将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。 ## Consequences -macOS/Windows 分发物省去 WASM 下载和展开资源。原生包缺失成为必须修复的安装缺陷;转换不会下载引擎。Linux 保留 WASM 的资源和字体要求。kit 的引擎选择测试与真实 npm 安装测试覆盖平台选择;Harness 的 sidecar、wheel 和运行时解析测试覆盖打包要求。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 +声明了原生目标的分发物省去 WASM 资源,其余目标保留 WASM 的资源和字体要求。锁定的 kit 声明了 macOS/Windows ARM64 和 x64 原生包,因此当前 Linux 分发物选择 WASM;kit 发布版本可以新增 Linux 原生目标,无需修改 Harness 的选择规则。这不扩展 Harness 支持的发布平台。Harness 的 sidecar、wheel 和运行时解析测试覆盖已声明原生目标与 WASM 选择,包括原生包缺失。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 [公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 2fa729e15a..b6e91951f1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: f08fab5911450a615fc9b5cf750268b029a24f5d -README.zh.md: d82a4f21e1bfcd9fbc56376033e4811bf4bc644d +README.md: df2e01dd70b07cfe9c8982874fb337ec976f7b9f +README.zh.md: 470d21abd4f70b718fac1fd305b38a5448598c54 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f08fab5911..df2e01dd70 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -116,7 +116,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. 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; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d82a4f21e1..470d21abd4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -116,7 +116,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 18048375be..e5c789c165 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -28,6 +28,7 @@ import { } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' +import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -123,10 +124,12 @@ async function main(): Promise { const targetName = resolveDesktopBuildTarget() const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } const modules = join(BUILD_ROOT, 'node_modules') + const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) + const officeEngine = selectOfficeEngine(officeManifest, target) mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { recursive: true, dereference: true, - filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined, }) writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', @@ -137,7 +140,6 @@ async function main(): Promise { throw new Error(`desktop runtime: missing private Host file ${file}`) } } - const officeEngine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 9e6ea293ab..1bc16b2a1c 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -6,10 +6,11 @@ * node_modules tree, including nested package containers. * @param path - Path relative to the production node_modules directory. * @param target - Platform and architecture of the Electron Node runtime. + * @param officeEngine - Engine selected from the installed kit manifest. * @returns Omission reason, or undefined when the entry must be copied. */ export function desktopRuntimeFileExclusion( - path: string, target: { platform: NodeJS.Platform; arch: string }, + path: string, target: { platform: NodeJS.Platform; arch: string }, officeEngine: string, ): string | undefined { const parts = path.split(/[\\/]/u) if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { @@ -24,8 +25,7 @@ export function desktopRuntimeFileExclusion( const name = packageParts.slice(0, nameParts).join('/') const entry = packageParts.slice(nameParts).join('/') if (name.startsWith('@deepseek-ai/libreoffice-kit-')) { - const engine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` - if (name !== `@deepseek-ai/libreoffice-kit-${engine}`) return 'LibreOffice other platform' + if (name !== `@deepseek-ai/libreoffice-kit-${officeEngine}`) return 'LibreOffice other platform' } if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { return 'fs-ext compiler output' diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 283a4f5842..76f5c8d95e 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -59,7 +59,7 @@ it('omits development artifacts while preserving executable modules, assets and } cpSync(modules, join(output, 'node_modules'), { recursive: true, dereference: true, - filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows, 'win32-x64') === undefined, }) for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false) for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`) @@ -72,15 +72,28 @@ it('omits development artifacts while preserving executable modules, assets and }) it('applies package-specific rules inside scoped and nested dependency containers', () => { - expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined() - expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined() }) it('retains native prebuilds for the selected macOS architecture', () => { const mac = { platform: 'darwin' as const, arch: 'arm64' } - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac, 'darwin-arm64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac, 'darwin-arm64')).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac, 'darwin-arm64')).toBeDefined() +}) + +it.each([ + ['linux', 'x64', 'linux-x64'], + ['linux', 'arm64', 'wasm'], + ['freebsd', 'x64', 'wasm'], +] as const)('retains only the selected Office engine for %s/%s', (platform, arch, engine) => { + for (const candidate of ['linux-x64', 'darwin-arm64', 'wasm']) { + const omitted = desktopRuntimeFileExclusion( + `@deepseek-ai/libreoffice-kit-${candidate}/prebuilds.json`, { platform, arch }, engine, + ) + expect(omitted === undefined).toBe(candidate === engine) + } }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 69c6a8af46..70ea668046 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: ebe0bb074ad2d0c3739b83ddb3f08d1165e85277 -capability-seams.zh.md: 16707057fcd3f654c635f950c5f7fb77f23a3248 +capability-seams.md: 929173ab44c9f4cb4dbff8871fb473d9abff0141 +capability-seams.zh.md: f0526445a86fb0b0875bbca1382607c28b9db867 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index ebe0bb074a..929173ab44 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -533,7 +533,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `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.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 16707057fc..f0526445a8 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -535,7 +535,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `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.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 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(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/subsystems/document-convert.i18n.yaml b/docs/subsystems/document-convert.i18n.yaml index 8ec3e22776..47919b364c 100644 --- a/docs/subsystems/document-convert.i18n.yaml +++ b/docs/subsystems/document-convert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/document-convert.md -document-convert.md: 2dea31918e92524606ad85826de276c2af05f06d -document-convert.zh.md: 22f0ec54615ebbf86743de6320ec4e8cc3fc8113 +document-convert.md: 1e17edb6314ef7c99cbf29313f64faa6a4b918cf +document-convert.zh.md: 478043bae519ecab2d3c2ccbc46426f14c9c4f6e diff --git a/docs/subsystems/document-convert.md b/docs/subsystems/document-convert.md index 2dea31918e..1e17edb631 100644 --- a/docs/subsystems/document-convert.md +++ b/docs/subsystems/document-convert.md @@ -29,7 +29,7 @@ The provider admits the deferred read before allocating source bytes, shares con ## Engine selection and limits -The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/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. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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-harness/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 decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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 [Host provider configuration](../../packages/document/document-convert-libreoffice/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. diff --git a/docs/subsystems/document-convert.zh.md b/docs/subsystems/document-convert.zh.md index 22f0ec5461..478043bae5 100644 --- a/docs/subsystems/document-convert.zh.md +++ b/docs/subsystems/document-convert.zh.md @@ -29,7 +29,7 @@ ## 引擎选择和限制 -外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 +外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 [Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index 5c7acd04ef..59329ef29d 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/document/README.md -README.md: 0f9d3f1ae0cb154a445e34debe0bf49ef5400d1e -README.zh.md: 0325634d6c435b6d8aeebf202d04f84fa4975e33 +README.md: 693a831fb7a501f8c0376271715298c06b33efd8 +README.zh.md: ac4719874761c9267c25eee763013b5f7f5d9290 diff --git a/packages/document/README.md b/packages/document/README.md index 0f9d3f1ae0..693a831fb7 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. Targets with a declared native engine use it; other targets use Node WASM. ## Table of Contents diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index 0325634d6c..ac47198747 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 ## 目录 diff --git a/packages/document/document-convert-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml index 13f28d09b0..4aaae7abaf 100644 --- a/packages/document/document-convert-libreoffice/README.i18n.yaml +++ b/packages/document/document-convert-libreoffice/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/document/document-convert-libreoffice/README.md -README.md: 1c287b714ace289fa03f8be6da722fbe78e64065 -README.zh.md: 796d14c3bd873a3a6a24d4691f0d406d0894a122 +README.md: c083215ea2e3fa067043dac045becd892e0ca406 +README.zh.md: d10c1a14792c80b3608b3302958de4546b817cda diff --git a/packages/document/document-convert-libreoffice/README.md b/packages/document/document-convert-libreoffice/README.md index 1c287b714a..c083215ea2 100644 --- a/packages/document/document-convert-libreoffice/README.md +++ b/packages/document/document-convert-libreoffice/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert Office documents to PDFs on the Host computer. macOS and Windows use their matching native LibreOffice engine; Linux uses 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. +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. ## Table of Contents @@ -27,7 +27,7 @@ Convert Office documents to PDFs on the Host computer. macOS and Windows use the The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. -The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. +The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. 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 [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. | Field | Default | Meaning | |---|---|---| diff --git a/packages/document/document-convert-libreoffice/README.zh.md b/packages/document/document-convert-libreoffice/README.zh.md index 796d14c3bd..d10c1a1479 100644 --- a/packages/document/document-convert-libreoffice/README.zh.md +++ b/packages/document/document-convert-libreoffice/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在宿主计算机上将 Office 文档转换为 PDF。macOS 和 Windows 使用匹配的原生 LibreOffice 引擎;Linux 使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 +在宿主计算机上将 Office 文档转换为 PDF。声明了原生 LibreOffice 引擎的目标使用原生引擎,其余目标使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-reference" [Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 -此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 +此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/document/document-convert-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json index f16000844f..eb020306ef 100644 --- a/packages/document/document-convert-libreoffice/package.json +++ b/packages/document/document-convert-libreoffice/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-document-convert-libreoffice", - "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", + "description": "Host Office conversion with a declared native target engine or Node WASM", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 631a6680d5..70ecb5d18f 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: ba1814ce00dea9dd35bcfbb330801942b86af155 -README.zh.md: 792c7e06a29be987a25887882b6df976138b194f +README.md: adb2fb71c3893d9360d7593826fdca609f770b6e +README.zh.md: a4decd5040ada700e84b0f1b3bacba7bbec8a56f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ba1814ce00..adb2fb71c3 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -39,4 +39,4 @@ Production deployment permits unused workspace patches for packages outside the From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. -The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: native on macOS/Windows and WASM on Linux. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 792c7e06a2..a4decd5040 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -39,4 +39,4 @@ Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 -已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:macOS/Windows 使用原生引擎,Linux 使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 +已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index d642eec5e2..23551b2705 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -74,7 +74,6 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] target = next(name for name, value in _PLATFORMS.items() if value[0] == platform_tag) - engine = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] @@ -96,9 +95,15 @@ class RuntimeBuildHook(BuildHookInterface): ) for executable in runtime_files: if executable == office: - for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): - if not (office / "node_modules" / required).is_file(): - raise RuntimeError(f"runtime Office dependency is missing: {office / required}") + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + if not adapter.is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {adapter}") + native = target.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + required = office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json" + if not required.is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {required}") continue if not executable.is_file(): raise RuntimeError(f"runtime executable is not a file: {executable}") diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 546bab36c7..48c2b26057 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -21,6 +21,7 @@ console command requires ``DSH_HOME`` for the same reason. from __future__ import annotations +import json import os import platform import shutil @@ -89,13 +90,20 @@ def bundled_runtime_path() -> Path: + _EXE_ACQUISITION_HINT ) office = path.with_name(f"{path.name.removesuffix('.exe')}-office") - engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") - for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): - if not (office / "node_modules" / required).is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " - + _EXE_ACQUISITION_HINT - ) + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + if not adapter.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " + + _EXE_ACQUISITION_HINT + ) + native = tag.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + if not (office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json").is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar engine {engine} at {office}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b3a9e259ef..39022df00c 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -164,6 +164,7 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> @pytest.mark.parametrize("platform_tag,selected", [ ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"), ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"), + ("manylinux_2_28_x86_64", "linux-x64"), ("macosx_14_0_arm64", "wasm"), ]) @pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"]) def test_office_wheel_requires_only_target_engine( @@ -177,7 +178,9 @@ def test_office_wheel_requires_only_target_engine( engine = ({"kind": "native", "executable": "bin/helper"} if native else {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"}) with zipfile.ZipFile(wheel, "w") as archive: - archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", "{}") + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", json.dumps({ + "optionalDependencies": {f"@deepseek-ai/libreoffice-kit-{selected}": "0.0.1"}, + })) base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}" if invalid != "missing-engine": archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine})) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 0c6fa828a2..a1b071e7a0 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import subprocess import sys @@ -19,14 +20,19 @@ from deepseek_harness_runtime import ( ) -def _office_sidecar(executable: Path) -> Path: +def _office_sidecar(executable: Path, native_targets: tuple[str, ...] = ("darwin-arm64", "darwin-x64", "win32-x64")) -> Path: office = executable.with_name(f"{executable.name.removesuffix('.exe')}-office") tag = executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") - engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + native = tag.replace("win-", "win32-").replace("macos-", "darwin-") + engine = native if native in native_targets else "wasm" for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): path = office / "node_modules" / required path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{}") + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + adapter.write_text(json.dumps({"optionalDependencies": { + f"@deepseek-ai/libreoffice-kit-{target}": "0.0.1" for target in (*native_targets, "wasm") + }}), encoding="utf-8") return office @@ -229,8 +235,14 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: assert sentinel.read_text() == "done" -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "win-x64"]) -def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None: +@pytest.mark.parametrize("target,native_targets", [ + ("linux-x64", ()), ("linux-arm64", ()), + ("macos-arm64", ("darwin-arm64",)), ("macos-x64", ("darwin-x64",)), ("win-x64", ("win32-x64",)), + ("linux-x64", ("linux-x64",)), ("macos-arm64", ()), +]) +def test_runtime_requires_its_platform_office_engine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str, native_targets: tuple[str, ...], +) -> None: extension = ".exe" if target.startswith("win-") else "" executable = tmp_path / "runtime" / f"deepseek-harness-sdk-runtime-{target}{extension}" executable.parent.mkdir() @@ -238,13 +250,13 @@ def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch executable.with_name(f"{executable.stem}-rg{extension}").touch() if target.startswith("macos-"): Path(f"{executable}-spawn-helper").touch() - office = _office_sidecar(executable) + office = _office_sidecar(executable, native_targets) monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: target) assert runtime.bundled_runtime_path() == executable engine = next(office.glob("node_modules/@deepseek-ai/libreoffice-kit-*/prebuilds.json")) engine.unlink() - foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if target.startswith("linux-") else "libreoffice-kit-wasm") / "prebuilds.json" + foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if engine.parent.name == "libreoffice-kit-wasm" else "libreoffice-kit-wasm") / "prebuilds.json" foreign.parent.mkdir(parents=True, exist_ok=True) foreign.write_text("{}") with pytest.raises(FileNotFoundError, match="Office sidecar"): diff --git a/scripts/build-exe-for-python-sdk-office.spec.ts b/scripts/build-exe-for-python-sdk-office.spec.ts index de7ae69b5b..273bf874e8 100644 --- a/scripts/build-exe-for-python-sdk-office.spec.ts +++ b/scripts/build-exe-for-python-sdk-office.spec.ts @@ -94,33 +94,37 @@ it('rejects an ancestor dependency outside the deployed closure', async () => { .rejects.toThrow('outside the deployed closure') }) -it('rejects an optional WASM engine resolved outside the deployed closure', async () => { +it('requires the declared WASM engine inside the deployed closure', async () => { const { root, staging, destination, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) - const ambient = await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) - .rejects.toThrow(`Python Office dependency is outside the deployed closure: ${ambient}`) + .rejects.toThrow('Python Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) }) it.each([ - ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'arm64'], ['win32', 'x64'], ['linux', 'x64'], -])('copies only the %s/%s engine even when other engines are staged', async (platform, arch) => { + ['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'], + ['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'], + ['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], ['freebsd', 'x64', 'wasm'], +])('copies only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => { const { staging, destination, packageAt } = await fixture() - const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'linux-x64', 'wasm'] const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`) await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) }) for (const name of names) await packageAt(name) - const expected = `@deepseek-ai/libreoffice-kit-${platform === 'linux' ? 'wasm' : `${platform}-${arch}`}` + const expected = `@deepseek-ai/libreoffice-kit-${selected}` const packages = await copyOfficeSidecar(staging, destination, { platform, arch }) expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ 'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`, ]) }) -it.each(['darwin', 'win32'])('%s requires its native package even when WASM is staged', async (platform) => { +it.each(['darwin', 'win32', 'linux'])('%s requires its native package even when WASM is staged', async (platform) => { const { staging, destination, packageAt } = await fixture() - await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { + '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', [`@deepseek-ai/libreoffice-kit-${platform}-arm64`]: '0.0.1', + } }) await packageAt('@deepseek-ai/libreoffice-kit-wasm') await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' })) .rejects.toThrow(`Python Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`) diff --git a/scripts/build-exe-for-python-sdk-office.ts b/scripts/build-exe-for-python-sdk-office.ts index 50385ed0f5..0233dca72f 100644 --- a/scripts/build-exe-for-python-sdk-office.ts +++ b/scripts/build-exe-for-python-sdk-office.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import { cp, mkdir, readFile, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { isAbsolute, join, relative, sep } from 'node:path' +import { selectOfficeEngine } from './libreoffice-engine.ts' /** pkg applies these exclusions to dependency `files` as well as root asset globs. */ export const OFFICE_ASSET_IGNORES = [ @@ -22,7 +23,7 @@ interface PackageManifest { /** * Copy the installed Office dependency tree without changing package contents or executable modes. - * Harness sidecars require the target native engine on macOS/Windows and WASM on Linux. + * Harness sidecars require the kit's declared target native engine, or WASM for other targets. * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail. * @param staging - Symlink-free deployed Node closure. * @param destination - Target-specific Office directory beside the executable; replaced when present. @@ -34,7 +35,9 @@ export async function copyOfficeSidecar( destination: string, target: { platform: string; arch: string }, ): Promise { - const engineName = `@deepseek-ai/libreoffice-kit-${target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`}` + const entry = join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit') + const manifest = JSON.parse(await readFile(join(entry, 'package.json'), 'utf8')) as PackageManifest + const engineName = `@deepseek-ai/libreoffice-kit-${selectOfficeEngine(manifest, target)}` const packages = new Set() async function visit(packageDirectory: string): Promise { @@ -53,7 +56,7 @@ export async function copyOfficeSidecar( ...Object.keys(manifest.peerDependencies ?? {}), ]) for (const name of dependencies) { - if (name.startsWith('@deepseek-ai/libreoffice-kit-') && name !== engineName) continue + if (name.startsWith('@deepseek-ai/libreoffice-kit-')) continue const optional = manifest.optionalDependencies?.[name] !== undefined || manifest.peerDependenciesMeta?.[name]?.optional === true const dependencyDirectory = (require.resolve.paths(name) ?? []) @@ -71,7 +74,7 @@ export async function copyOfficeSidecar( } } - await visit(join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit')) + await visit(entry) const engineDirectory = join(staging, 'node_modules', engineName) if (!existsSync(engineDirectory)) throw new Error(`Python Office engine ${engineName} required for ${target.platform}/${target.arch} is missing.`) await visit(engineDirectory) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 04500c942b..4e5bfb4e1e 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -237,7 +237,9 @@ def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platfor raise RuntimeError("Office dependency is missing: libreoffice-kit") engines = f"{office_modules}/@deepseek-ai" target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag) - selected = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") + native_target = target.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(archive.read(adapter)).get("optionalDependencies", {}) + selected = native_target if f"@deepseek-ai/libreoffice-kit-{native_target}" in declared else "wasm" for required in (f"libreoffice-kit-{selected}/prebuilds.json",): if f"{engines}/{required}" not in names: raise RuntimeError(f"Office dependency is missing: {required}") diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e9bc5c90c0..07e5cd952b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -153,7 +153,7 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], - note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', + note: 'Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared.', }, { key: 'attachments', diff --git a/scripts/libreoffice-engine.spec.ts b/scripts/libreoffice-engine.spec.ts new file mode 100644 index 0000000000..763a313a75 --- /dev/null +++ b/scripts/libreoffice-engine.spec.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import { selectOfficeEngine } from './libreoffice-engine.ts' + +it.each([ + ['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'], + ['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'], + ['linux', 'x64', 'wasm'], ['linux', 'arm64', 'wasm'], + ['darwin', 'other', 'wasm'], ['freebsd', 'x64', 'wasm'], +])('selects the declared engine for %s/%s', (platform, arch, expected) => { + const optionalDependencies = Object.fromEntries( + ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + .map(engine => [`@deepseek-ai/libreoffice-kit-${engine}`, '0.0.1']), + ) + expect(selectOfficeEngine({ optionalDependencies }, { platform, arch })).toBe(expected) +}) + +it('selects a declared Linux native target without an OS-specific policy change', () => { + expect(selectOfficeEngine({ optionalDependencies: { '@deepseek-ai/libreoffice-kit-linux-x64': '1' } }, + { platform: 'linux', arch: 'x64' })).toBe('linux-x64') +}) + +it('uses WASM when no native targets are declared', () => { + expect(selectOfficeEngine({}, { platform: 'darwin', arch: 'x64' })).toBe('wasm') +}) diff --git a/scripts/libreoffice-engine.ts b/scripts/libreoffice-engine.ts new file mode 100644 index 0000000000..7b3b270daa --- /dev/null +++ b/scripts/libreoffice-engine.ts @@ -0,0 +1,17 @@ +/** Select the single LibreOffice payload from the kit's declared native targets. */ + +/** + * Require a declared native target even when its optional npm package is absent. + * Targets without a declared native engine use WASM. + * @param manifest - Installed LibreOffice kit package manifest. + * @param target - Node platform and architecture of the distribution. + * @returns Engine suffix used by the kit's npm packages. + */ +export function selectOfficeEngine( + manifest: { optionalDependencies?: Record }, + target: { platform: string; arch: string }, +): string { + const native = `${target.platform}-${target.arch}` + return Object.hasOwn(manifest.optionalDependencies ?? {}, `@deepseek-ai/libreoffice-kit-${native}`) + ? native : 'wasm' +} diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 6f551d6773..04d5dff158 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -873,7 +873,11 @@ def smoke_sdk_office(executable: Path) -> None: else: shutil.copy2(source, destination) office = root / f"{stem}-office" - expected_backend = "wasm" if sys.platform == "linux" else "native" + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + native = stem.removeprefix("deepseek-harness-sdk-runtime-").replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + selected = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + expected_backend = "wasm" if selected == "wasm" else "native" engines = [ json.loads(manifest.read_text())["engine"]["kind"] for manifest in (office / "node_modules/@deepseek-ai").glob("libreoffice-kit-*/prebuilds.json") From 68183a5de3010479763d7c158b13b03bcaac5c5b Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:07:38 +0800 Subject: [PATCH 04/18] docs(document): refresh shared conversion module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 7 +++++++ docs/module-graph.zh.md | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a6cd1fdedd..83ee1103d7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: ae72394aa8ab8cf0d00d9d6cf707a17281292517 -module-graph.zh.md: 65fac7e5ccf176d65a2b600c8652820c95884788 +module-graph.md: 1d3fa93237e94d1347f512d1f7714b47eef91401 +module-graph.zh.md: f80d2b20b0a81cb282e4e097bcfed0851f6d3e81 diff --git a/docs/module-graph.md b/docs/module-graph.md index ae72394aa8..1d3fa93237 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -221,6 +221,10 @@ flowchart TD pkg_credentials["credentials"] pkg_credentials_local["credentials-local"] end + subgraph group_document["packages/document"] + pkg_document_convert["document-convert"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] + end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] @@ -405,6 +409,7 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants + pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1365,6 +1370,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | +| [`document-convert`](../packages/document/document-convert) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1393,6 +1399,7 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 65fac7e5cc..f80d2b20b0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -223,6 +223,10 @@ flowchart TD pkg_credentials["credentials"] pkg_credentials_local["credentials-local"] end + subgraph group_document["packages/document"] + pkg_document_convert["document-convert"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] + end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] @@ -407,6 +411,7 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants + pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1367,6 +1372,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | +| [`document-convert`](../packages/document/document-convert) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1395,6 +1401,7 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | From d3ed04defaeeb8e10fbc7867afc83085ed734239 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:50:04 +0800 Subject: [PATCH 05/18] refactor(document): consolidate Office conversion into office-to-pdf --- ...9-14-independent-libreoffice-kit.i18n.yaml | 4 +- .../2026-09-14-independent-libreoffice-kit.md | 2 +- ...26-09-14-independent-libreoffice-kit.zh.md | 2 +- ...6-09-15-bounded-office-rendering.i18n.yaml | 4 +- .../2026-09-15-bounded-office-rendering.md | 6 +- .../2026-09-15-bounded-office-rendering.zh.md | 6 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 10 +- docs/capability-seams.zh.md | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 101 +++++++++--------- docs/config-catalog.zh.md | 101 +++++++++--------- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 7 +- docs/module-graph.zh.md | 7 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- ...vert.i18n.yaml => office-to-pdf.i18n.yaml} | 6 +- .../{document-convert.md => office-to-pdf.md} | 25 +++-- ...ment-convert.zh.md => office-to-pdf.zh.md} | 25 +++-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 3 +- .../web-app/tests/document-conversion.e2e.ts | 20 ++-- packages/document/README.i18n.yaml | 4 +- packages/document/README.md | 7 +- packages/document/README.zh.md | 7 +- .../README.i18n.yaml | 6 -- .../document-convert-libreoffice/package.json | 43 -------- packages/document/document-convert/README.md | 75 ------------- .../document/document-convert/README.zh.md | 75 ------------- .../document/document-convert/src/index.ts | 44 -------- .../document/document-convert/src/types.ts | 71 ------------ .../document/document-convert/tsconfig.json | 18 ---- .../README.i18n.yaml | 6 +- .../README.md | 10 +- .../README.zh.md | 10 +- .../package.json | 18 ++-- packages/document/office-to-pdf/src/errors.ts | 15 +++ .../document/office-to-pdf/src/identity.ts | 31 ++++++ .../src/index.ts | 49 ++++++--- .../src/output.ts | 12 +-- .../src/queue.ts | 49 ++++----- packages/document/office-to-pdf/src/types.ts | 43 ++++++++ .../tests/output.spec.ts | 0 .../tests/provider.spec.ts | 21 ++-- .../tests/queue.spec.ts | 13 ++- .../tsconfig.json | 2 +- .../node/external_packages/libreoffice-kit.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 94 ++++++++-------- pnpm-lock.yaml | 21 +--- scripts/gen-cordis-catalog.ts | 10 +- scripts/gen-doc-graphs.ts | 4 +- .../verify-package-readme-model-experience.ts | 3 +- tsconfig.base.json | 5 +- tsconfig.host.json | 3 +- 62 files changed, 453 insertions(+), 697 deletions(-) rename docs/subsystems/{document-convert.i18n.yaml => office-to-pdf.i18n.yaml} (67%) rename docs/subsystems/{document-convert.md => office-to-pdf.md} (59%) rename docs/subsystems/{document-convert.zh.md => office-to-pdf.zh.md} (58%) delete mode 100644 packages/document/document-convert-libreoffice/README.i18n.yaml delete mode 100644 packages/document/document-convert-libreoffice/package.json delete mode 100644 packages/document/document-convert/README.md delete mode 100644 packages/document/document-convert/README.zh.md delete mode 100644 packages/document/document-convert/src/index.ts delete mode 100644 packages/document/document-convert/src/types.ts delete mode 100644 packages/document/document-convert/tsconfig.json rename packages/document/{document-convert => office-to-pdf}/README.i18n.yaml (69%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/README.md (85%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/README.zh.md (84%) rename packages/document/{document-convert => office-to-pdf}/package.json (62%) create mode 100644 packages/document/office-to-pdf/src/errors.ts create mode 100644 packages/document/office-to-pdf/src/identity.ts rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/index.ts (79%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/output.ts (66%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/queue.ts (82%) create mode 100644 packages/document/office-to-pdf/src/types.ts rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/output.spec.ts (100%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/provider.spec.ts (93%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/queue.spec.ts (97%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tsconfig.json (89%) diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml index 7f6119ae93..c3570fad78 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md -2026-09-14-independent-libreoffice-kit.md: f9f27585d157025477e5f5d5f6d74dfca142ce83 -2026-09-14-independent-libreoffice-kit.zh.md: 18bf20e28c8f66ea581ac7823f7b004e89153b90 +2026-09-14-independent-libreoffice-kit.md: 13efc41e56a4810273fbb6d9314eeda6053dbe92 +2026-09-14-independent-libreoffice-kit.zh.md: 720ca2ba3f57546825ae14b5b5f0b086fbb7f287 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md index f9f27585d1..13efc41e56 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -10,7 +10,7 @@ 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 `DocumentConverter` 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 `OfficeToPdf` 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. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md index 18bf20e28c..720ca2ba3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -10,7 +10,7 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布 ## Decision -`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentConverter` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeToPdf` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml index 9cb08e42e0..f4d8c83c91 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: c8ffd5f476e9c5a7c7a0f85706872b2da1690681 -2026-09-15-bounded-office-rendering.zh.md: a03a605dd66142d6457d35154a0e1628dcfa7632 +2026-09-15-bounded-office-rendering.md: 242434361e665359ebfa6d934b5257fc0df12ed9 +2026-09-15-bounded-office-rendering.zh.md: b8268a5e9c421bb7b205136f66213acaa7aa8548 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md index c8ffd5f476..242434361e 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -10,9 +10,9 @@ Office preview and explicit document inspection can request the same conversion. ## Decision -The `document-convert` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. +The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. -The [Host provider](../../../../packages/document/document-convert-libreoffice/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 renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +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 renderer 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,6 +28,8 @@ 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. + ## Consequences The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md index a03a605dd6..b8268a5e9c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -10,9 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 ## 决策 -`document-convert` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 +`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 -[宿主提供方](../../../../packages/document/document-convert-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 @@ -28,6 +28,8 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 + ## 后果 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ef94c86975..34637d9bef 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: a76e1d0d44f7ef935d9514f5bfd4b271cda6e074 -README.zh.md: 8911afee6a5f1ec9df8c0dabf93aa81f21bc1571 +README.md: cd303f6557143e14aece0dd323286fbee41e54da +README.zh.md: 55f70a092cbd42d3fc8802891014f5d147a7d2ca diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a76e1d0d44..cd303f6557 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -120,7 +120,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. 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; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8911afee6a..55f70a092c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -120,7 +120,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 584c607e92..f2afd33425 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 6b0b48b86cc5413fdc122ce3225577f86cbd6001 -capability-seams.zh.md: 7d714141a5e8deb4b7be0ca176db520a58a48832 +capability-seams.md: 88308afe9d8acff4d021f57ce5884cace44c894d +capability-seams.zh.md: a81cbb977d85234f203db64309ef5cfa0268f3f3 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6b0b48b86c..88308afe9d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,9 +26,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_convert["document-convert"] - svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] + svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -284,8 +283,6 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_convert --> svc_documentConvert - pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -318,6 +315,7 @@ flowchart LR pkg_mcp_client --> svc_mcpResources pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback + pkg_office_to_pdf --> svc_officeToPdf pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_plugin_manager --> svc_pluginManager @@ -535,7 +533,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `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.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7d714141a5..a81cbb977d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,9 +28,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_convert["document-convert"] - svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] + svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -286,8 +285,6 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_convert --> svc_documentConvert - pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -320,6 +317,7 @@ flowchart LR pkg_mcp_client --> svc_mcpResources pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback + pkg_office_to_pdf --> svc_officeToPdf pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_plugin_manager --> svc_pluginManager @@ -537,7 +535,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `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.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 已授权的 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(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ecdcb52e27..08c47ee2ef 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a9885dec32cf39a260960559d628b9c1ce219c1a -config-catalog.zh.md: 481aa16a57f6e8828809a2956bcc04d573c6cabe +config-catalog.md: 7f9a195843af4bef80634ddf1501e30d71eff859 +config-catalog.zh.md: 6ce8b9ebb185ebb2f7991de9ed4db9de060de66d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a9885dec32..7f9a195843 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,56 +548,6 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - - -## `@deepseek-ai/dsh-document-convert-libreoffice` - -```ts config-catalog -/** Provider concurrency and kit rendering/font configuration. */ -export interface Config { - /** Maximum simultaneous conversions; queued callers remain cancellable. */ - maxConcurrentConversions: number - /** Maximum metadata-only jobs awaiting source admission. */ - maxQueuedJobs: number - /** Maximum outstanding conversion readers. */ - maxReaders: number - /** Maximum reserved bytes across admitted source reads and conversions. */ - maxSourceBytes: number - /** Maximum concurrent background jobs; zero refuses speculative work. */ - maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ - maxCachedEntries: number - /** Maximum retained PDF bytes. */ - maxCachedBytes: number - /** Maximum retained source-version aliases to cached content. */ - maxSourceEntries: number - /** Conversion deadline in milliseconds; excludes the DSH queue. */ - timeoutMs: number - /** Maximum authorized source bytes. */ - maxInputBytes: number - /** Maximum complete PDF bytes. */ - maxOutputBytes: number - /** Exported raster-image DPI. */ - maxImageResolution: number - /** Maximum OOXML ZIP entries. */ - maxArchiveEntries: number - /** Maximum total declared uncompressed OOXML bytes. */ - maxUncompressedBytes: number - /** Absolute font roots; omission uses the kit's platform defaults. */ - fontDirectories?: string[] - /** Ordered font-family preference groups; omission retains the kit defaults. */ - fontFallbacks?: string[][] - /** Maximum physical font files indexed by each converter. */ - maxFontFiles: number - /** Maximum individual font-file bytes. */ - maxFontFileBytes: number - /** Maximum original font bytes loaded for a conversion. */ - maxLoadedFontBytes: number -} -``` - -Source: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) - ## `@deepseek-ai/dsh-experimental-agent-team` @@ -1768,6 +1718,56 @@ export interface Config { Source: [`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-office-to-pdf` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +Source: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -3805,7 +3805,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-convert` — abstract `DocumentConverter` ([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 481aa16a57..6ce8b9ebb1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,56 +550,6 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - - -## `@deepseek-ai/dsh-document-convert-libreoffice` - -```ts config-catalog -/** Provider concurrency and kit rendering/font configuration. */ -export interface Config { - /** Maximum simultaneous conversions; queued callers remain cancellable. */ - maxConcurrentConversions: number - /** Maximum metadata-only jobs awaiting source admission. */ - maxQueuedJobs: number - /** Maximum outstanding conversion readers. */ - maxReaders: number - /** Maximum reserved bytes across admitted source reads and conversions. */ - maxSourceBytes: number - /** Maximum concurrent background jobs; zero refuses speculative work. */ - maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ - maxCachedEntries: number - /** Maximum retained PDF bytes. */ - maxCachedBytes: number - /** Maximum retained source-version aliases to cached content. */ - maxSourceEntries: number - /** Conversion deadline in milliseconds; excludes the DSH queue. */ - timeoutMs: number - /** Maximum authorized source bytes. */ - maxInputBytes: number - /** Maximum complete PDF bytes. */ - maxOutputBytes: number - /** Exported raster-image DPI. */ - maxImageResolution: number - /** Maximum OOXML ZIP entries. */ - maxArchiveEntries: number - /** Maximum total declared uncompressed OOXML bytes. */ - maxUncompressedBytes: number - /** Absolute font roots; omission uses the kit's platform defaults. */ - fontDirectories?: string[] - /** Ordered font-family preference groups; omission retains the kit defaults. */ - fontFallbacks?: string[][] - /** Maximum physical font files indexed by each converter. */ - maxFontFiles: number - /** Maximum individual font-file bytes. */ - maxFontFileBytes: number - /** Maximum original font bytes loaded for a conversion. */ - maxLoadedFontBytes: number -} -``` - -源码: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) - ## `@deepseek-ai/dsh-experimental-agent-team` @@ -1770,6 +1720,56 @@ export interface Config { 来源:[`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-office-to-pdf` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +来源: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -3807,7 +3807,6 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-convert` — 抽象 `DocumentConverter`([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 83ee1103d7..c06a093929 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 1d3fa93237e94d1347f512d1f7714b47eef91401 -module-graph.zh.md: f80d2b20b0a81cb282e4e097bcfed0851f6d3e81 +module-graph.md: 2949593ccf9f67732d0a91b375e6a1b0e613e502 +module-graph.zh.md: d80d5a5947926753d0ef344884a3f4df4fad53e7 diff --git a/docs/module-graph.md b/docs/module-graph.md index 1d3fa93237..2949593ccf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -222,8 +222,7 @@ flowchart TD pkg_credentials_local["credentials-local"] end subgraph group_document["packages/document"] - pkg_document_convert["document-convert"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] @@ -409,7 +408,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1370,7 +1368,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | -| [`document-convert`](../packages/document/document-convert) | `document` | — | +| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1399,7 +1397,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f80d2b20b0..d80d5a5947 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -224,8 +224,7 @@ flowchart TD pkg_credentials_local["credentials-local"] end subgraph group_document["packages/document"] - pkg_document_convert["document-convert"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] @@ -411,7 +410,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1372,7 +1370,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | -| [`document-convert`](../packages/document/document-convert) | `document` | — | +| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1401,7 +1399,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 7b8d06b446..09d0f62e1a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: f3593271de63f0436ccd0e704e787c7436b1ac00 -README.zh.md: 4f5ffba4b757e2d835a630cea14c09c7ecdb4980 +README.md: a9202f07f10c15d4e15a26615887753c0d17beb2 +README.zh.md: bfcbae5b68927db72180ce4c7032884752c37089 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index f3593271de..a9202f07f1 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -29,7 +29,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 | -| [document-convert.md](document-convert.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | +| [office-to-pdf.md](office-to-pdf.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 | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 4f5ffba4b7..bfcbae5b68 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -29,7 +29,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 约定 | -| [document-convert.md](document-convert.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | +| [office-to-pdf.md](office-to-pdf.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_*` 环境词汇 | diff --git a/docs/subsystems/document-convert.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml similarity index 67% rename from docs/subsystems/document-convert.i18n.yaml rename to docs/subsystems/office-to-pdf.i18n.yaml index 47919b364c..edca358c9c 100644 --- a/docs/subsystems/document-convert.i18n.yaml +++ b/docs/subsystems/office-to-pdf.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/document-convert.md -document-convert.md: 1e17edb6314ef7c99cbf29313f64faa6a4b918cf -document-convert.zh.md: 478043bae519ecab2d3c2ccbc46426f14c9c4f6e +# pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md +office-to-pdf.md: 71254a3a8a8b4665d8d42b935cc836cb7c644b10 +office-to-pdf.zh.md: c11889fc9bd4693d90ec60696fce4b57693fd027 diff --git a/docs/subsystems/document-convert.md b/docs/subsystems/office-to-pdf.md similarity index 59% rename from docs/subsystems/document-convert.md rename to docs/subsystems/office-to-pdf.md index 1e17edb631..71254a3a8a 100644 --- a/docs/subsystems/document-convert.md +++ b/docs/subsystems/office-to-pdf.md @@ -1,6 +1,6 @@ -# Document conversion +# Office to PDF -English | [中文](document-convert.zh.md) +English | [中文](office-to-pdf.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,15 +8,14 @@ The [document package family](../../packages/document/README.md) converts Office | Owner | Responsibility | |---|---| -| [document-convert](../../packages/document/document-convert/README.md) | `ctx.documentConvert`: authorized Office bytes and complete PDF results | -| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching | | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | ## Requests and results -[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentConverter.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentConvertError`. +[`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`. -`DocumentConvertPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentConverterGeneration` brands a provider lifetime, and `DocumentConvertKey` brands its content identity; neither opaque value is parsed by consumers. +`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. | Result field | Meaning | |---|---| @@ -31,7 +30,7 @@ The provider admits the deferred read before allocating source bytes, shares con The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/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 decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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 [Host provider configuration](../../packages/document/document-convert-libreoffice/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-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. @@ -41,11 +40,11 @@ The [Host provider configuration](../../packages/document/document-convert-libre Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentConvert` — `DocumentConverter` (abstract seam) +### `ctx.officeToPdf` — `OfficeToPdf` -Load one provider subclass per context; consumers own source authorization. +A provider lifetime owns all converters, queued calls, and temporary files. ```ts cordis-catalog /** @@ -53,10 +52,10 @@ Load one provider subclass per context; consumers own source authorization. * @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 {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) +Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) diff --git a/docs/subsystems/document-convert.zh.md b/docs/subsystems/office-to-pdf.zh.md similarity index 58% rename from docs/subsystems/document-convert.zh.md rename to docs/subsystems/office-to-pdf.zh.md index 478043bae5..c11889fc9b 100644 --- a/docs/subsystems/document-convert.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -1,6 +1,6 @@ -# 文档转换 +# Office 转 PDF -[English](document-convert.md) | 中文 +[English](office-to-pdf.md) | 中文 [document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 @@ -8,15 +8,14 @@ | 所有者 | 职责 | |---|---| -| [document-convert](../../packages/document/document-convert/README.zh.md) | `ctx.documentConvert`:已授权 Office 字节和完整 PDF 结果 | -| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 | | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | ## 请求和结果 -[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentConverter.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentConvertError` 拒绝。 +[`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` 拒绝。 -`DocumentConvertPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentConverterGeneration` 表示提供方生命周期,`DocumentConvertKey` 表示其内容身份;消费者不解析这两种不透明值。 +`OfficeToPdfPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeToPdfGeneration` 表示提供方生命周期,`OfficeToPdfKey` 表示其内容身份;消费者不解析这两种不透明值。 | 结果字段 | 含义 | |---|---| @@ -31,7 +30,7 @@ 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 -[Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 +[Host 提供方配置](../../packages/document/office-to-pdf/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 @@ -41,11 +40,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentConvert` — `DocumentConverter` (abstract seam) +### `ctx.officeToPdf` — `OfficeToPdf` -Load one provider subclass per context; consumers own source authorization. +A provider lifetime owns all converters, queued calls, and temporary files. ```ts cordis-catalog /** @@ -53,10 +52,10 @@ Load one provider subclass per context; consumers own source authorization. * @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 {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) +Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index c2f7020c88..13322367f4 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 2b5c4f48f95fc40b7e25c33e4b14a510c0ace34f -README.zh.md: a8117f8f7d1c3f12016d37c37b81e5a8734531aa +README.md: f733218e857eaf42a8fb2779646b941c5cc1bd4f +README.zh.md: 5c058067c6f849486229e9e400109565eccc3aea diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 2b5c4f48f9..f733218e85 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-convert` row mounts one lazy [Office conversion provider](../../document/document-convert-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. +The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, 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. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index a8117f8f7d..5c058067c6 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-convert` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-convert-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 120ae2a0c4..edae44c230 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,8 +225,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - - id: document-convert - name: '@deepseek-ai/dsh-document-convert-libreoffice' + - id: office-to-pdf + name: '@deepseek-ai/dsh-office-to-pdf' # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index a31ffaab1e..ee0672cb39 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -122,8 +122,7 @@ "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-document-convert": "workspace:^", - "@deepseek-ai/dsh-document-convert-libreoffice": "workspace:^" + "@deepseek-ai/dsh-office-to-pdf": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts index 2f66681703..5e80c99113 100644 --- a/packages/bundle/web-app/tests/document-conversion.e2e.ts +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -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 { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' -import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-convert-libreoffice' +import { OfficeSourceKey, type OfficeToPdfRequest } from '@deepseek-ai/dsh-office-to-pdf' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-to-pdf' 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-document-convert-libreoffice') - expect(rows.map(row => row.id)).toEqual(['document-convert']) - const configured = applyEntryPatches(rows, [{ id: 'document-convert', config: { maxConcurrentConversions: 1 } }], + .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 } }], (message) => { throw new Error(message) }) const configPath = join(directory, 'cordis.yml') await writeFile(configPath, JSON.stringify(configured)) @@ -33,21 +33,21 @@ 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-document-convert-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + if (specifier !== '@deepseek-ai/dsh-office-to-pdf') throw new Error(`Unexpected plugin: ${specifier}`) return LibreOfficeProvider }, } as unknown as NonNullable 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 === 'document-convert')! + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')! await entry.fiber!.await() expect(ctx.get('documentRenderController')).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): DocumentConvertRequest => ({ + const request = (key: string): OfficeToPdfRequest => ({ extension: 'docx', priority: 'foreground', - source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, + source: { key: OfficeSourceKey(key), version: 'fixture', bytes: bytes.length, async read(signal, maxBytes) { signal.throwIfAborted() expect(bytes.length).toBeLessThanOrEqual(maxBytes) @@ -56,7 +56,7 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }, }, }) - const provider = ctx.documentConvert + const provider = ctx.officeToPdf 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-') diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index 59329ef29d..4eb834b43a 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/document/README.md -README.md: 693a831fb7a501f8c0376271715298c06b33efd8 -README.zh.md: ac4719874761c9267c25eee763013b5f7f5d9290 +README.md: 8993c608169ef08abe45babcf33ce6552c147425 +README.zh.md: c5775ae4deb9f4dea36df104f93c8e016e44abbd diff --git a/packages/document/README.md b/packages/document/README.md index 693a831fb7..8993c60816 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. Targets with a declared native engine use it; other targets use Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The shared service converts through LibreOffice kit. Targets with a declared native engine use it; other targets use Node WASM. ## Table of Contents @@ -26,8 +26,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference | Package | Role | ctx key | |---|---|---| -| [document-convert](document-convert/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentConvert` | -| [document-convert-libreoffice](document-convert-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentConvert` | +| [office-to-pdf](office-to-pdf/README.md) | Authorized Office bytes to complete PDFs with bounded queues and caching | `ctx.officeToPdf` | ----- @@ -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/document-convert.md) — shared operation and generated service reference. +- [Document conversion](../../docs/subsystems/office-to-pdf.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. diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index ac47198747..c5775ae4de 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。共享服务使用 LibreOffice kit 执行转换。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 ## 目录 @@ -26,8 +26,7 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| -| [document-convert](document-convert/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentConvert` | -| [document-convert-libreoffice](document-convert-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentConvert` | +| [office-to-pdf](office-to-pdf/README.zh.md) | 将已授权 Office 字节转换为完整 PDF,并提供有界队列和缓存 | `ctx.officeToPdf` | ----- @@ -36,7 +35,7 @@ kind: "package-group" 消费者负责源文件授权与展示。 -- [文档转换](../../docs/subsystems/document-convert.zh.md) — 共享操作和生成的服务参考。 +- [文档转换](../../docs/subsystems/office-to-pdf.zh.md) — 共享操作和生成的服务参考。 - [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 - [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 diff --git a/packages/document/document-convert-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml deleted file mode 100644 index 4aaae7abaf..0000000000 --- a/packages/document/document-convert-libreoffice/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/document/document-convert-libreoffice/README.md -README.md: c083215ea2e3fa067043dac045becd892e0ca406 -README.zh.md: d10c1a14792c80b3608b3302958de4546b817cda diff --git a/packages/document/document-convert-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json deleted file mode 100644 index eb020306ef..0000000000 --- a/packages/document/document-convert-libreoffice/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-document-convert-libreoffice", - "description": "Host Office conversion with a declared native target engine or Node WASM", - "version": "0.1.6-alpha.1", - "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/document-convert-libreoffice" - }, - "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:^", - "@deepseek-ai/dsh-document-convert": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-convert": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/libreoffice-kit": "0.0.1", - "@deepseek-ai/schemastery": "workspace:^" - } -} diff --git a/packages/document/document-convert/README.md b/packages/document/document-convert/README.md deleted file mode 100644 index fbc98feef3..0000000000 --- a/packages/document/document-convert/README.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: "Authorized Office input and complete PDF output for Host conversion providers." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-document-convert - -English | [中文](README.zh.md) - -## Summary - -Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes to a complete PDF. Callers receive PDF bytes and, for OOXML inputs, missing-font families without changing the original file or adding model context. - -## 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) - ------ - - -## Use this package - -Mount the [LibreOffice provider](../document-convert-libreoffice/README.md) to supply `ctx.documentConvert`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `convert()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentConvertError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentConvertError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. - -
- ------ - - -## Further Exploration - -- [Document conversion](../../../docs/subsystems/document-convert.md) — composition and ownership. -- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. - ------ - - -## 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 - - - -- The service accepts only the six listed Office formats; source authorization belongs to consumers, while shared conversion reuse belongs to the provider. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/document/document-convert/README.zh.md b/packages/document/document-convert/README.zh.md deleted file mode 100644 index 3fa1865203..0000000000 --- a/packages/document/document-convert/README.zh.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF 输出。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-document-convert - -[English](README.md) | 中文 - -## 概述 - -将已授权的 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 字节转换为完整 PDF。调用方获得 PDF 字节;OOXML 输入还提供缺失字体名称,不修改原文件或添加模型上下文。 - -## 目录 - -- [使用此包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [延伸阅读](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延后工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用此包 - -挂载 [LibreOffice 提供方](../document-convert-libreoffice/README.zh.md)以提供 `ctx.documentConvert`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `convert()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 - ------ - - -## 理解实现 - -
-实现内部细节 — 点击展开 - -提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和转换 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentConvertError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentConvertError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 - -
- ------ - - -## 延伸阅读 - -- [文档转换](../../../docs/subsystems/document-convert.zh.md) — 组合和所有权。 -- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 - ------ - - -## 模型体验 - -无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 - -#### KV Cache effect - -无;转换不会构造或修改模型请求。 - -## 已知限制与延后工作 - - - -- 服务仅接受上述六种 Office 格式;源文件授权由消费者负责,共享转换复用由提供方负责。 - - -### 开发备注 - -
-维护者工作上下文 — 点击展开 - -无。 - -
diff --git a/packages/document/document-convert/src/index.ts b/packages/document/document-convert/src/index.ts deleted file mode 100644 index 014e71c280..0000000000 --- a/packages/document/document-convert/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Host document conversion: authorized Office bytes become a complete PDF. */ -import { Context, Service } from '@deepseek-ai/cordis' -import type { DocumentConvertErrorCode, DocumentConvertRequest, DocumentConvertResult, DocumentConverterGeneration } from './types.ts' - -export * from './types.ts' - -/** Classified conversion failure; engine details stay in the cause. */ -export class DocumentConvertError 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: DocumentConvertErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'DocumentConvertError' - } -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** One Host converter for already-authorized Office bytes. */ - documentConvert: DocumentConverter - } -} - -/** Load one provider subclass per context; consumers own source authorization. */ -export abstract class DocumentConverter extends Service { - constructor(ctx: Context) { super(ctx, 'documentConvert') } - - /** Changes whenever engine, font, or conversion configuration is replaced. */ - abstract readonly generation: DocumentConverterGeneration - - /** - * Convert Office bytes without modifying the source or writing Session events. - * @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 {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ - abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise -} - -export default DocumentConverter diff --git a/packages/document/document-convert/src/types.ts b/packages/document/document-convert/src/types.ts deleted file mode 100644 index 35f63911ac..0000000000 --- a/packages/document/document-convert/src/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Authorized Office input and complete PDF output, independent of the conversion engine. */ -import { brandString, type Branded } from '@deepseek-ai/dsh-brand' - -/** Binary Office and Office Open XML formats supported by document preview. */ -export type DocumentExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' - -/** Authorized execution scope and canonical source path, encoded by the consumer. */ -export type DocumentSourceKey = Branded<'DocumentSourceKey'> -/** - * Label an authorized source locator for pre-read deduplication. - * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. - * @returns branded source locator; source authorization remains the caller's responsibility. - */ -export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } - -/** One provider lifetime, including its engine, rendering settings, and font configuration. */ -export type DocumentConverterGeneration = Branded<'DocumentConverterGeneration'> -/** - * Label a provider lifetime. - * @param value - unique generation created by the provider. - * @returns branded converter generation. - */ -export function DocumentConverterGeneration(value: string): DocumentConverterGeneration { - return brandString(value) -} - -/** Provider generation and source-content digest; consumers must not parse it. */ -export type DocumentConvertKey = Branded<'DocumentConvertKey'> -/** - * Label a converter-owned content identity. - * @param value - generation and content identity created by the provider. - * @returns branded conversion identity. - */ -export function DocumentConvertKey(value: string): DocumentConvertKey { return brandString(value) } - -/** Foreground previews and explicit QA precede speculative background conversion. */ -export type DocumentConvertPriority = 'foreground' | 'background' - -/** Source authorization and metadata lookup must finish before submitting a request. */ -export interface DocumentConvertRequest { - readonly extension: DocumentExtension - readonly priority: DocumentConvertPriority - readonly source: { - readonly key: DocumentSourceKey - readonly version: string - /** Authorized stat size; omission reserves the provider's entire input limit. */ - readonly bytes?: number - /** - * Read only after provider admission; do not capture already-buffered input in queued production requests. - * @param signal - shared conversion lifetime, independent of an individual reader. - * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. - * @returns owned bytes and the actual read version; a changed version rejects conversion. - */ - read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> - } -} - -/** Successful conversion; failed and interrupted conversions reject instead. */ -export interface DocumentConvertResult { - /** Caller-owned complete PDF, valid after provider disposal. */ - readonly pdf: Uint8Array - /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ - readonly missingFonts: string[] - readonly cacheKey: DocumentConvertKey - readonly generation: DocumentConverterGeneration -} - -/** Failures a conversion consumer can present without exposing engine diagnostics. */ -export type DocumentConvertErrorCode = - | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' - | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-convert/tsconfig.json b/packages/document/document-convert/tsconfig.json deleted file mode 100644 index 85055ef176..0000000000 --- a/packages/document/document-convert/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../util/brand" - } - ] -} diff --git a/packages/document/document-convert/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml similarity index 69% rename from packages/document/document-convert/README.i18n.yaml rename to packages/document/office-to-pdf/README.i18n.yaml index b0aec675b0..b0019ba621 100644 --- a/packages/document/document-convert/README.i18n.yaml +++ b/packages/document/office-to-pdf/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/document/document-convert/README.md -README.md: fbc98feef30c4e32c255b82f57ea1aad5a68940d -README.zh.md: 3fa18652036d4823d45e3ccc38d03f599b3c4326 +# pnpm run verify-translation-pairing --write packages/document/office-to-pdf/README.md +README.md: 75f26612e649a4722543505cca9956541f07007b +README.zh.md: d9ecfb7347d22952b6100324926163649389773d diff --git a/packages/document/document-convert-libreoffice/README.md b/packages/document/office-to-pdf/README.md similarity index 85% rename from packages/document/document-convert-libreoffice/README.md rename to packages/document/office-to-pdf/README.md index c083215ea2..75f26612e6 100644 --- a/packages/document/document-convert-libreoffice/README.md +++ b/packages/document/office-to-pdf/README.md @@ -3,7 +3,7 @@ description: "Host Office conversion with the independently published LibreOffic kind: "package-reference" --- -# @deepseek-ai/dsh-document-convert-libreoffice +# @deepseek-ai/dsh-office-to-pdf English | [中文](README.zh.md) @@ -25,7 +25,9 @@ Convert Office documents to PDFs on the Host computer. Targets with a declared n ## Use this package -The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `office-to-pdf`. Independent compositions mount `@deepseek-ai/dsh-office-to-pdf` 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.officeToPdf.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 `OfficeToPdfError`. The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. 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 [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. @@ -38,7 +40,7 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k | `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-document-convert-libreoffice) 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 [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-office-to-pdf) 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 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. @@ -65,7 +67,7 @@ Each concurrent slot lazily creates and reuses one kit converter. The provider w ## Further Exploration -- [Rendering service](../document-convert/README.md) — input and result ownership. +- [Office to PDF](../../../docs/subsystems/office-to-pdf.md) — composition and input/result ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-convert-libreoffice/README.zh.md b/packages/document/office-to-pdf/README.zh.md similarity index 84% rename from packages/document/document-convert-libreoffice/README.zh.md rename to packages/document/office-to-pdf/README.zh.md index d10c1a1479..d9ecfb7347 100644 --- a/packages/document/document-convert-libreoffice/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -3,7 +3,7 @@ description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文 kind: "package-reference" --- -# @deepseek-ai/dsh-document-convert-libreoffice +# @deepseek-ai/dsh-office-to-pdf [English](README.md) | 中文 @@ -25,7 +25,9 @@ kind: "package-reference" ## 使用此包 -[Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 +[Web bundle](../../bundle/web-app/README.zh.md)以 `office-to-pdf` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-office-to-pdf`。 + +调用方通过 `ctx.officeToPdf.convert()` 提交已授权源的标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。源版本变化会拒绝转换。结果包含调用方拥有的 PDF 字节、缺失字体、缓存键和转换 generation;配置替换后 generation 随之改变。取消以原因为拒绝值,转换失败使用 `OfficeToPdfError`。 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 @@ -38,7 +40,7 @@ kind: "package-reference" | `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | | `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | -[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-convert-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-office-to-pdf)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 @@ -65,7 +67,7 @@ kind: "package-reference" ## 延伸阅读 -- [渲染服务](../document-convert/README.zh.md) — 输入和结果所有权。 +- [Office 转 PDF](../../../docs/subsystems/office-to-pdf.zh.md) — 组合与输入、结果所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-convert/package.json b/packages/document/office-to-pdf/package.json similarity index 62% rename from packages/document/document-convert/package.json rename to packages/document/office-to-pdf/package.json index bb7805f3cc..e31095398f 100644 --- a/packages/document/document-convert/package.json +++ b/packages/document/office-to-pdf/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-document-convert", - "description": "Authorized Office bytes to PDF rendering service", + "name": "@deepseek-ai/dsh-office-to-pdf", + "description": "Shared Office-to-PDF conversion with bounded queues and caching", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -12,19 +12,19 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-convert" + "directory": "packages/document/office-to-pdf" }, "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json", "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" + } }, "files": [ "lib/index.js", @@ -35,9 +35,13 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^" }, "dependencies": { + "@deepseek-ai/libreoffice-kit": "0.0.1", + "@deepseek-ai/schemastery": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^" } } diff --git a/packages/document/office-to-pdf/src/errors.ts b/packages/document/office-to-pdf/src/errors.ts new file mode 100644 index 0000000000..838bd679c4 --- /dev/null +++ b/packages/document/office-to-pdf/src/errors.ts @@ -0,0 +1,15 @@ +/** Classified failures shared by conversion and its consumers. */ +import type { OfficeToPdfErrorCode } from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class OfficeToPdfError 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) { + super(message, options) + this.name = 'OfficeToPdfError' + } +} diff --git a/packages/document/office-to-pdf/src/identity.ts b/packages/document/office-to-pdf/src/identity.ts new file mode 100644 index 0000000000..313b3e3e2f --- /dev/null +++ b/packages/document/office-to-pdf/src/identity.ts @@ -0,0 +1,31 @@ +/** Source locators and converter-owned identities for shared PDF reuse. */ +import { brandString, type Branded } from '@deepseek-ai/dsh-brand' + +/** Authorized execution scope and canonical source path, encoded by the consumer. */ +export type OfficeSourceKey = Branded<'OfficeSourceKey'> +/** + * Label an authorized source locator for pre-read deduplication. + * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. + * @returns branded source locator; source authorization remains the caller's responsibility. + */ +export function OfficeSourceKey(key: string): OfficeSourceKey { return brandString(key) } + +/** One provider lifetime, including its engine, rendering settings, and font configuration. */ +export type OfficeToPdfGeneration = Branded<'OfficeToPdfGeneration'> +/** + * Label a provider lifetime. + * @param value - unique generation created by the provider. + * @returns branded converter generation. + */ +export function OfficeToPdfGeneration(value: string): OfficeToPdfGeneration { + return brandString(value) +} + +/** Provider generation and source-content digest; consumers must not parse it. */ +export type OfficeToPdfKey = Branded<'OfficeToPdfKey'> +/** + * 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(value) } diff --git a/packages/document/document-convert-libreoffice/src/index.ts b/packages/document/office-to-pdf/src/index.ts similarity index 79% rename from packages/document/document-convert-libreoffice/src/index.ts rename to packages/document/office-to-pdf/src/index.ts index 3f4ef308c2..f585fc3184 100644 --- a/packages/document/document-convert-libreoffice/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -3,13 +3,26 @@ import { randomUUID } from 'node:crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join } from 'node:path' -import type { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' -import { DocumentConverter, DocumentConvertError, DocumentConverterGeneration, type DocumentExtension, type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import z from '@deepseek-ai/schemastery' +import { OfficeToPdfError } from './errors.ts' +import { OfficeToPdfGeneration } from './identity.ts' +import type { OfficeExtension, OfficeToPdfRequest, OfficeToPdfResult } from './types.ts' import { readPdf } from './output.ts' import { ConversionQueue } from './queue.ts' +export * from './errors.ts' +export * from './identity.ts' +export * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Shared Host conversion of already-authorized Office bytes to PDF. */ + officeToPdf: OfficeToPdf + } +} + /** Provider concurrency and kit rendering/font configuration. */ export interface Config { /** Maximum simultaneous conversions; queued callers remain cancellable. */ @@ -81,9 +94,10 @@ interface Slot { } /** A provider lifetime owns all converters, queued calls, and temporary files. */ -export class LibreOfficeConverter extends DocumentConverter { +export class OfficeToPdf extends Service { static Config = Config - readonly generation = DocumentConverterGeneration(randomUUID()) + /** Changes whenever engine, font, or conversion configuration is replaced. */ + readonly generation: OfficeToPdfGeneration = OfficeToPdfGeneration(randomUUID()) private readonly slots: Slot[] = [] private readonly queue: ConversionQueue private readonly options: ConverterOptions @@ -93,7 +107,7 @@ export class LibreOfficeConverter extends DocumentConverter { * @param config - resolved rendering, font, and concurrency limits. */ constructor(ctx: Context, private readonly config: Config) { - super(ctx) + super(ctx, 'officeToPdf') if (config.fontDirectories?.some(path => !isAbsolute(path))) throw new Error('fontDirectories must contain absolute paths.') if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.') const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, @@ -115,11 +129,18 @@ export class LibreOfficeConverter extends DocumentConverter { }) } - convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise { + /** + * Convert Office bytes without modifying the source or writing Session events. + * @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. + */ + convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise { return this.queue.read(request, signal) } - private async convertBytes(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + private async convertBytes(bytes: Uint8Array, extension: OfficeExtension, signal: AbortSignal): Promise> { signal.throwIfAborted() let slot = this.slots.find(candidate => !candidate.busy) if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } @@ -134,7 +155,7 @@ export class LibreOfficeConverter extends DocumentConverter { } const converter = await slot.converter signal.throwIfAborted() - directory = await mkdtemp(join(tmpdir(), 'dsh-document-convert-')) + directory = await mkdtemp(join(tmpdir(), 'dsh-office-to-pdf-')) const inputPath = join(directory, `source.${extension}`) const outputPath = join(directory, 'converted.pdf') await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) @@ -144,20 +165,20 @@ export class LibreOfficeConverter extends DocumentConverter { let pdf: Uint8Array try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } catch (cause) { - if (cause instanceof DocumentConvertError) throw cause - throw new DocumentConvertError('invalid-output', 'The converter PDF could not be read.', { cause }) + if (cause instanceof OfficeToPdfError) throw cause + throw new OfficeToPdfError('invalid-output', 'The converter PDF could not be read.', { cause }) } signal.throwIfAborted() return { pdf, missingFonts: result.missingFonts } } catch (cause) { signal.throwIfAborted() - if (cause instanceof DocumentConvertError) throw cause + if (cause instanceof OfficeToPdfError) throw cause const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined switch (code) { case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': case 'invalid-output': case 'timeout': case 'unavailable': - throw new DocumentConvertError(code, 'LibreOffice conversion failed.', { cause }) - default: throw new DocumentConvertError('failed', 'LibreOffice conversion failed.', { cause }) + throw new OfficeToPdfError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new OfficeToPdfError('failed', 'LibreOffice conversion failed.', { cause }) } } finally { try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } @@ -166,4 +187,4 @@ export class LibreOfficeConverter extends DocumentConverter { } } -export default LibreOfficeConverter +export default OfficeToPdf diff --git a/packages/document/document-convert-libreoffice/src/output.ts b/packages/document/office-to-pdf/src/output.ts similarity index 66% rename from packages/document/document-convert-libreoffice/src/output.ts rename to packages/document/office-to-pdf/src/output.ts index 41dee85ee2..46f79d7d19 100644 --- a/packages/document/document-convert-libreoffice/src/output.ts +++ b/packages/document/office-to-pdf/src/output.ts @@ -1,7 +1,7 @@ /** Read only the bounded, regular PDF created inside a private task directory. */ import { constants } from 'node:fs' import { lstat, open } from 'node:fs/promises' -import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' +import { OfficeToPdfError } from './errors.ts' /** * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. @@ -13,12 +13,12 @@ import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { signal.throwIfAborted() const entry = await lstat(path) - if (!entry.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') + if (!entry.isFile()) throw new OfficeToPdfError('invalid-output', 'The converter output is not a regular file.') const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await file.stat() - if (!info.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') - if (info.size > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (!info.isFile()) throw new OfficeToPdfError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new OfficeToPdfError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const bytes = Buffer.alloc(info.size + 1) let length = 0 while (length < bytes.length) { @@ -28,11 +28,11 @@ export async function readPdf(path: string, limit: number, signal: AbortSignal): length += read.bytesRead } signal.throwIfAborted() - if (length > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (length > limit) throw new OfficeToPdfError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const pdf = bytes.subarray(0, length) if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { - throw new DocumentConvertError('invalid-output', 'The converter did not produce a complete PDF.') + throw new OfficeToPdfError('invalid-output', 'The converter did not produce a complete PDF.') } return pdf } finally { await file.close() } diff --git a/packages/document/document-convert-libreoffice/src/queue.ts b/packages/document/office-to-pdf/src/queue.ts similarity index 82% rename from packages/document/document-convert-libreoffice/src/queue.ts rename to packages/document/office-to-pdf/src/queue.ts index 6ed9b0f487..6270512e07 100644 --- a/packages/document/document-convert-libreoffice/src/queue.ts +++ b/packages/document/office-to-pdf/src/queue.ts @@ -1,34 +1,35 @@ /** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ import { createHash } from 'node:crypto' -import { DocumentConvertError, DocumentConvertKey, type DocumentExtension, type DocumentConverterGeneration, - type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' +import { OfficeToPdfError } from './errors.ts' +import { OfficeToPdfKey, type OfficeToPdfGeneration } from './identity.ts' +import type { OfficeExtension, OfficeToPdfRequest, OfficeToPdfResult } from './types.ts' import type { Config } from './index.ts' -type Converted = Pick -type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise +type Converted = Pick +type Convert = (bytes: Uint8Array, extension: OfficeExtension, signal: AbortSignal) => Promise interface Reader { job: Job readonly source: string - readonly priority: DocumentConvertRequest['priority'] - readonly resolve: (value: DocumentConvertResult) => void + readonly priority: OfficeToPdfRequest['priority'] + readonly resolve: (value: OfficeToPdfResult) => void readonly reject: (error: unknown) => void readonly cleanup: () => void } interface Job { - readonly request: DocumentConvertRequest + readonly request: OfficeToPdfRequest readonly controller: AbortController readonly readers: Set readonly sources: Set - priority: DocumentConvertRequest['priority'] + priority: OfficeToPdfRequest['priority'] state: 'queued' | 'running' | 'finished' } /** One converter generation owns every queued source, conversion, reader, and retained PDF. */ export class ConversionQueue { - private readonly ready = new Map() - private readonly aliases = new Map() + private readonly ready = new Map() + private readonly aliases = new Map() private readonly sources = new Map() - private readonly digests = new Map() + private readonly digests = new Map() private readonly queue: Job[] = [] private readonly tasks = new Set>() private readonly jobs = new Set() @@ -44,7 +45,7 @@ export class ConversionQueue { * @param generation - provider lifetime; prevents reuse after engine or font replacement. * @param convert - executes one admitted conversion and settles after scratch cleanup. */ - constructor(private readonly config: Config, private readonly generation: DocumentConverterGeneration, + constructor(private readonly config: Config, private readonly generation: OfficeToPdfGeneration, private readonly convert: Convert) {} /** @@ -53,11 +54,11 @@ export class ConversionQueue { * @param signal - this reader's cancellation; the final reader cancels shared work. * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. */ - async read(request: DocumentConvertRequest, signal?: AbortSignal): Promise { + async read(request: OfficeToPdfRequest, signal?: AbortSignal): Promise { signal?.throwIfAborted() if (this.disposed) throw this.unavailable() if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { - throw new DocumentConvertError('input-too-large', 'The Office source exceeds maxInputBytes.') + throw new OfficeToPdfError('input-too-large', 'The Office source exceeds maxInputBytes.') } const source = JSON.stringify([request.source.key, request.source.version, request.extension]) const alias = this.aliases.get(source) @@ -87,7 +88,7 @@ export class ConversionQueue { const shared = job if (request.priority === 'foreground') shared.priority = 'foreground' this.readers++ - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const abort = (): void => { this.release(reader) const reason: unknown = signal?.reason @@ -114,9 +115,9 @@ export class ConversionQueue { await Promise.allSettled(this.tasks) } - private busy(): DocumentConvertError { return new DocumentConvertError('busy', 'The document converter has reached its admission limit.') } - private unavailable(): DocumentConvertError { return new DocumentConvertError('unavailable', 'The document converter is unavailable.') } - private copy(result: DocumentConvertResult): DocumentConvertResult { + private busy(): OfficeToPdfError { return new OfficeToPdfError('busy', 'The document converter has reached its admission limit.') } + private unavailable(): OfficeToPdfError { return new OfficeToPdfError('unavailable', 'The document converter is unavailable.') } + private copy(result: OfficeToPdfResult): OfficeToPdfResult { return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } } private release(reader: Reader): void { @@ -177,10 +178,10 @@ export class ConversionQueue { signal.throwIfAborted() const input = await job.request.source.read(signal, reserved) signal.throwIfAborted() - if (input.version !== job.request.source.version) throw new DocumentConvertError('source-changed', 'The source changed while waiting for conversion.') - if (input.bytes.byteLength > reserved) throw new DocumentConvertError('input-too-large', 'The source exceeds its reserved read capacity.') + if (input.version !== job.request.source.version) throw new OfficeToPdfError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new OfficeToPdfError('input-too-large', 'The source exceeds its reserved read capacity.') const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') - const key = DocumentConvertKey(`${this.generation}:${digest}`) + const key = OfficeToPdfKey(`${this.generation}:${digest}`) const cached = this.ready.get(key) if (cached !== undefined) { this.ready.delete(key) @@ -207,7 +208,7 @@ export class ConversionQueue { } finally { if (this.digests.get(key) === job) this.digests.delete(key) } } - private finish(job: Job, result: DocumentConvertResult): void { + private finish(job: Job, result: OfficeToPdfResult): void { for (const source of job.sources) this.sources.delete(source) if (this.ready.has(result.cacheKey)) { for (const source of job.sources) { @@ -218,10 +219,10 @@ export class ConversionQueue { } for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } } - private retain(result: DocumentConvertResult): void { + private retain(result: OfficeToPdfResult): void { if (result.pdf.byteLength > this.config.maxCachedBytes) return while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { - const [key, oldest] = this.ready.entries().next().value as [DocumentConvertKey, DocumentConvertResult] + const [key, oldest] = this.ready.entries().next().value as [OfficeToPdfKey, OfficeToPdfResult] this.ready.delete(key) this.cachedBytes -= oldest.pdf.byteLength for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts new file mode 100644 index 0000000000..58d015152e --- /dev/null +++ b/packages/document/office-to-pdf/src/types.ts @@ -0,0 +1,43 @@ +/** Authorized Office input and complete PDF output, for the shared Host converter. */ +import type { OfficeSourceKey, OfficeToPdfGeneration, OfficeToPdfKey } from './identity.ts' +export type { OfficeSourceKey, OfficeToPdfGeneration, OfficeToPdfKey } from './identity.ts' + +/** Binary Office and Office Open XML formats supported by Office-to-PDF conversion. */ +export type OfficeExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' + +/** Foreground previews and explicit QA precede speculative background conversion. */ +export type OfficeToPdfPriority = 'foreground' | 'background' + +/** Source authorization and metadata lookup must finish before submitting a request. */ +export interface OfficeToPdfRequest { + readonly extension: OfficeExtension + readonly priority: OfficeToPdfPriority + readonly source: { + readonly key: OfficeSourceKey + readonly version: string + /** Authorized stat size; omission reserves the provider's entire input limit. */ + readonly bytes?: number + /** + * Read only after provider admission; do not capture already-buffered input in queued production requests. + * @param signal - shared conversion lifetime, independent of an individual reader. + * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. + * @returns owned bytes and the actual read version; a changed version rejects conversion. + */ + read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> + } +} + +/** Successful conversion; failed and interrupted conversions reject instead. */ +export interface OfficeToPdfResult { + /** Caller-owned complete PDF, valid after provider disposal. */ + readonly pdf: Uint8Array + /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ + readonly missingFonts: string[] + readonly cacheKey: OfficeToPdfKey + readonly generation: OfficeToPdfGeneration +} + +/** Failures a conversion consumer can present without exposing engine diagnostics. */ +export type OfficeToPdfErrorCode = + | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' + | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-convert-libreoffice/tests/output.spec.ts b/packages/document/office-to-pdf/tests/output.spec.ts similarity index 100% rename from packages/document/document-convert-libreoffice/tests/output.spec.ts rename to packages/document/office-to-pdf/tests/output.spec.ts diff --git a/packages/document/document-convert-libreoffice/tests/provider.spec.ts b/packages/document/office-to-pdf/tests/provider.spec.ts similarity index 93% rename from packages/document/document-convert-libreoffice/tests/provider.spec.ts rename to packages/document/office-to-pdf/tests/provider.spec.ts index d84e459ced..216b9e1f4c 100644 --- a/packages/document/document-convert-libreoffice/tests/provider.spec.ts +++ b/packages/document/office-to-pdf/tests/provider.spec.ts @@ -1,23 +1,22 @@ /** Disk output, resource bounds, and cancellation around the external kit. */ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { Context } from '@deepseek-ai/cordis' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import LibreOfficeConverter, { Config } from '../src/index.ts' +import OfficeToPdf, { Config, OfficeSourceKey, type OfficeToPdfRequest } from '../src/index.ts' const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') const input = new Uint8Array([80, 75, 3, 4]) -const request: DocumentConvertRequest = { extension: 'docx', priority: 'foreground', source: { - key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, +const request: OfficeToPdfRequest = { extension: 'docx', priority: 'foreground', source: { + key: OfficeSourceKey('source'), version: 'v1', bytes: input.length, read: async () => ({ bytes: input, version: 'v1' }), } } -function distinct(index: number): DocumentConvertRequest { - return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), +function distinct(index: number): OfficeToPdfRequest { + return { ...request, source: { ...request.source, key: OfficeSourceKey(`source-${index}`), read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } } let ctx: Context @@ -35,9 +34,9 @@ beforeEach(() => { }) afterEach(async () => { await ctx.fiber.dispose() }) -async function mount(config: Partial = {}): Promise { - await ctx.plugin(LibreOfficeConverter, config) - return ctx.documentConvert as LibreOfficeConverter +async function mount(config: Partial = {}): Promise { + await ctx.plugin(OfficeToPdf, config) + return ctx.officeToPdf } it('preserves the kit font defaults when font configuration is omitted', async () => { @@ -231,12 +230,12 @@ it('rejects caller cancellation before allocating any conversion resources', asy }) it('rejects relative font directories during provider configuration', async () => { - expect(() => new LibreOfficeConverter(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + expect(() => new OfficeToPdf(ctx, Config({ fontDirectories: ['relative/fonts'] }))) .toThrow('fontDirectories must contain absolute paths') }) it('rejects source capacity below one permitted input before allocating a converter', () => { - expect(() => new LibreOfficeConverter(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + expect(() => new OfficeToPdf(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) .toThrow('maxSourceBytes must be at least maxInputBytes') expect(kit.create).not.toHaveBeenCalled() }) diff --git a/packages/document/document-convert-libreoffice/tests/queue.spec.ts b/packages/document/office-to-pdf/tests/queue.spec.ts similarity index 97% rename from packages/document/document-convert-libreoffice/tests/queue.spec.ts rename to packages/document/office-to-pdf/tests/queue.spec.ts index ced4a3a646..7bfeedbaa9 100644 --- a/packages/document/document-convert-libreoffice/tests/queue.spec.ts +++ b/packages/document/office-to-pdf/tests/queue.spec.ts @@ -1,18 +1,17 @@ /** Controlled source and engine completions exercise admission and shared content ownership. */ -import { DocumentSourceKey, DocumentConverterGeneration, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { expect, it, onTestFinished, vi } from 'vitest' import { ConversionQueue } from '../src/queue.ts' -import { Config } from '../src/index.ts' +import { Config, OfficeSourceKey, OfficeToPdfGeneration, type OfficeToPdfRequest } from '../src/index.ts' const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } -function source(key: string, byte = 1, priority: DocumentConvertRequest['priority'] = 'foreground') { - const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) - const request: DocumentConvertRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } +function source(key: string, byte = 1, priority: OfficeToPdfRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: OfficeToPdfRequest = { extension: 'docx', priority, source: { key: OfficeSourceKey(key), version: 'v1', bytes: 1, read } } return { read, request } } function harness(config: Partial = {}) { const convert = vi.fn[2]>().mockResolvedValue(output) - const queue = new ConversionQueue(Config(config), DocumentConverterGeneration('test'), convert) + const queue = new ConversionQueue(Config(config), OfficeToPdfGeneration('test'), convert) onTestFinished(() => queue.dispose()) return { queue, convert } } @@ -336,7 +335,7 @@ it('separates Office extensions and converter generations in content identity', const h = harness(), a = source('a') const first = await h.queue.read(a.request) const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) - const other = new ConversionQueue(Config({}), DocumentConverterGeneration('replacement'), h.convert) + const other = new ConversionQueue(Config({}), OfficeToPdfGeneration('replacement'), h.convert) onTestFinished(() => other.dispose()) const replacement = await other.read(a.request) expect(first.cacheKey).not.toBe(otherFormat.cacheKey) diff --git a/packages/document/document-convert-libreoffice/tsconfig.json b/packages/document/office-to-pdf/tsconfig.json similarity index 89% rename from packages/document/document-convert-libreoffice/tsconfig.json rename to packages/document/office-to-pdf/tsconfig.json index 2199a71798..083e95a0ac 100644 --- a/packages/document/document-convert-libreoffice/tsconfig.json +++ b/packages/document/office-to-pdf/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../document-convert" + "path": "../../util/brand" } ] } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts index 7c76b2aea4..11aa009afe 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -3,7 +3,7 @@ import { notAvailableError } from '../notImplementedFail.ts' /** * Refuse converter creation with the kit's unavailable error code. - * @returns A rejected promise consumed by the Host document-convert provider. + * @returns A rejected promise consumed by the Host office-to-pdf provider. */ export function createConverter(): Promise { return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a08cb1e0ae..779952fe32 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -874,25 +874,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'documentConvert', - summary: 'Load one provider subclass per context; consumers own source authorization.', - description: 'Load one provider subclass per context; consumers own source authorization.', - methods: [ - { - signature: 'abstract readonly generation: DocumentConverterGeneration', - description: 'Changes whenever engine, font, or conversion configuration is replaced.', - parameters: [], - }, - { - signature: 'abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise', - description: 'Convert Office bytes without modifying the source or writing Session events.', - parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], - returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', - throws: ['{DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], - }, - ], - }, { key: 'fileReferences', summary: 'Host capability for cancellable file-reference discovery.', @@ -1389,6 +1370,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'officeToPdf', + summary: 'A provider lifetime owns all converters, queued calls, and temporary files.', + description: 'A provider lifetime owns all converters, queued calls, and temporary files.', + methods: [ + { + signature: 'readonly generation: OfficeToPdfGeneration = OfficeToPdfGeneration(randomUUID())', + description: 'Changes whenever engine, font, or conversion configuration is replaced.', + parameters: [], + }, + { + signature: 'convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise', + description: 'Convert Office bytes without modifying the source or writing Session events.', + parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: '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.'], + }, + ], + }, { key: 'permissionPresets', summary: 'Owns the deployment\'s configured permission presets, the fixed Auto integration hook, and their write path.', @@ -4442,34 +4442,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, - { - name: 'DocumentConverterGeneration', - declaration: 'export type DocumentConverterGeneration = Branded<\'DocumentConverterGeneration\'>;', - }, - { - name: 'DocumentConvertKey', - declaration: 'export type DocumentConvertKey = Branded<\'DocumentConvertKey\'>;', - }, - { - name: 'DocumentConvertPriority', - declaration: 'export type DocumentConvertPriority = \'foreground\' | \'background\';', - }, - { - name: 'DocumentConvertRequest', - declaration: 'export interface DocumentConvertRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentConvertPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', - }, - { - name: 'DocumentConvertResult', - declaration: 'export interface DocumentConvertResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentConvertKey;\n readonly generation: DocumentConverterGeneration;\n}', - }, - { - name: 'DocumentExtension', - declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', - }, - { - name: 'DocumentSourceKey', - declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', - }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', @@ -5114,6 +5086,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, + { + name: 'OfficeExtension', + declaration: 'export type OfficeExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', + }, + { + name: 'OfficeSourceKey', + declaration: 'export type OfficeSourceKey = Branded<\'OfficeSourceKey\'>;', + }, + { + name: 'OfficeToPdfGeneration', + declaration: 'export type OfficeToPdfGeneration = Branded<\'OfficeToPdfGeneration\'>;', + }, + { + name: 'OfficeToPdfKey', + declaration: 'export type OfficeToPdfKey = Branded<\'OfficeToPdfKey\'>;', + }, + { + name: 'OfficeToPdfPriority', + declaration: 'export type OfficeToPdfPriority = \'foreground\' | \'background\';', + }, + { + name: 'OfficeToPdfRequest', + declaration: 'export interface OfficeToPdfRequest {\n readonly extension: OfficeExtension;\n readonly priority: OfficeToPdfPriority;\n readonly source: {\n readonly key: OfficeSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'OfficeToPdfResult', + declaration: 'export interface OfficeToPdfResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: OfficeToPdfKey;\n readonly generation: OfficeToPdfGeneration;\n}', + }, { name: 'OneShotSubagentDescriptorData', declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a1bd6834e..f3808c9c09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2205,12 +2205,6 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner - '@deepseek-ai/dsh-document-convert': - specifier: workspace:^ - version: link:../../document/document-convert - '@deepseek-ai/dsh-document-convert-libreoffice': - specifier: workspace:^ - version: link:../../document/document-convert-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -2244,6 +2238,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-office-to-pdf': + specifier: workspace:^ + version: link:../../document/office-to-pdf '@deepseek-ai/dsh-session-log-export': specifier: workspace:^ version: link:../../session-query/session-log-export @@ -5814,18 +5811,11 @@ importers: specifier: workspace:^ version: link:../../util/launch-environment - packages/document/document-convert: + packages/document/office-to-pdf: dependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - - packages/document/document-convert-libreoffice: - dependencies: '@deepseek-ai/libreoffice-kit': specifier: 0.0.1 version: 0.0.1 @@ -5842,9 +5832,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-document-convert': - specifier: workspace:^ - version: link:../document-convert packages/experimental/agent-team: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7e94b1b937..7051b5a2dd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,7 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', - documentConvert: 'document-convert.md', + officeToPdf: 'office-to-pdf.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -274,10 +274,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', - DocumentConvertRequest: 'document-convert.md', - DocumentConvertResult: 'document-convert.md', - DocumentConvertPriority: 'document-convert.md', - DocumentConverterGeneration: 'document-convert.md', + OfficeToPdfRequest: 'office-to-pdf.md', + OfficeToPdfResult: 'office-to-pdf.md', + OfficeToPdfPriority: 'office-to-pdf.md', + OfficeToPdfGeneration: 'office-to-pdf.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 193ca0b6dd..205c2f4a2a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -151,8 +151,8 @@ const SERVICE_ROLES: ServiceRole[] = [ note: '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.', }, { - key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', - mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], + key: 'officeToPdf', pkg: 'office-to-pdf', title: 'Office to PDF conversion', + mode: 'core', consumers: [], note: 'Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index be715b9bd1..90e2aeb2da 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,8 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, - 'packages/document/document-convert': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, - 'packages/document/document-convert-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, + 'packages/document/office-to-pdf': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 7485ce059d..2df7de72ea 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": ["./packages/experimental/browser-use-stagehand-native/src/index.ts"], "@deepseek-ai/dsh-experimental-browser-use-runtime/mcp": ["./packages/experimental/browser-use-runtime/src/mcp.ts"], - "@deepseek-ai/dsh-document-convert/types": ["./packages/document/document-convert/src/types.ts"], + "@deepseek-ai/dsh-office-to-pdf/types": ["./packages/document/office-to-pdf/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], @@ -325,8 +325,6 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], - "@deepseek-ai/dsh-document-convert": ["./packages/document/document-convert/src"], - "@deepseek-ai/dsh-document-convert-libreoffice": ["./packages/document/document-convert-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], @@ -366,6 +364,7 @@ "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], + "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], "@deepseek-ai/dsh-package-manifest": ["./packages/util/package-manifest/src"], "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 98bd630b85..bcf23f4196 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -159,8 +159,7 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, - { "path": "./packages/document/document-convert" }, - { "path": "./packages/document/document-convert-libreoffice" }, + { "path": "./packages/document/office-to-pdf" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From 54d268c409e501726e61977a6878f2b94821d314 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:09:27 +0800 Subject: [PATCH 06/18] feat(preview): consume shared Office conversion independently --- .../2026-09-10-local-office-preview.i18n.yaml | 6 + .../2026-09-10-local-office-preview.md | 52 +++ .../2026-09-10-local-office-preview.zh.md | 52 +++ ...m-preview-font-and-image-budgets.i18n.yaml | 6 + ...-11-wasm-preview-font-and-image-budgets.md | 36 ++ ...-wasm-preview-font-and-image-budgets.zh.md | 36 ++ .agents/notes/archived/manifest.json | 9 + ...-independent-libreoffice-package.i18n.yaml | 6 + ...6-09-11-independent-libreoffice-package.md | 32 ++ ...9-11-independent-libreoffice-package.zh.md | 32 ++ ...9-08-document-preview-operations.i18n.yaml | 4 +- .../2026-09-08-document-preview-operations.md | 4 +- ...26-09-08-document-preview-operations.zh.md | 4 +- .../2026-09-11-node-office-kit.i18n.yaml | 6 + .../2026-09-11-node-office-kit.md | 65 +++ .../2026-09-11-node-office-kit.zh.md | 65 +++ apps/web/tests/document-preview.e2e.ts | 233 ++++++++++- apps/web/tests/expected/office-font-notice.md | 7 + .../tests/fixtures/office/README.i18n.yaml | 6 + apps/web/tests/fixtures/office/README.md | 7 + apps/web/tests/fixtures/office/README.zh.md | 7 + apps/web/tests/fixtures/office/preview.doc | Bin 0 -> 9216 bytes apps/web/tests/fixtures/office/preview.ppt | Bin 0 -> 606720 bytes apps/web/tests/fixtures/office/preview.xls | Bin 0 -> 5632 bytes apps/web/tests/office-fixture.ts | 52 +++ apps/web/tests/preview-boot.e2e.ts | 4 +- apps/web/tests/seeded-history.e2e.ts | 39 +- apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 +- docs/capability-seams.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 25 +- docs/config-catalog.zh.md | 25 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + docs/subsystems/office-to-pdf.i18n.yaml | 4 +- docs/subsystems/office-to-pdf.md | 37 ++ docs/subsystems/office-to-pdf.zh.md | 39 +- docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 6 +- docs/subsystems/sidebar-right.zh.md | 6 +- .../README.i18n.yaml | 6 + .../api/document-render-controller/README.md | 76 ++++ .../document-render-controller/README.zh.md | 76 ++++ .../document-render-controller/package.json | 84 ++++ .../src/client/index.ts | 16 + .../document-render-controller/src/index.ts | 107 +++++ .../document-render-controller/src/types.ts | 17 + .../tests/apply.client.spec.ts | 28 ++ .../tests/controller.spec.ts | 176 ++++++++ .../tsconfig.client.json | 29 ++ .../tsconfig.host.json | 31 ++ .../document-render-controller/tsconfig.json | 4 + .../tsdown.config.ts | 3 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 5 +- packages/bundle/web-app/package.json | 13 +- .../web-app/tests/document-preview.spec.ts | 131 ++++++ packages/client/ui-chat/tsconfig.json | 2 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 44 +- .../ui-sidebar-documentpreview/README.zh.md | 44 +- .../ui-sidebar-documentpreview/package.json | 16 +- .../src/client/TextPreview.module.css | 6 + .../src/client/TextPreview.tsx | 24 +- .../src/client/document/contract.ts | 12 +- .../src/client/document/registry.ts | 11 + .../src/client/face.ts | 65 +-- .../src/client/html/index.ts | 2 +- .../src/client/image/index.ts | 1 + .../src/client/index.ts | 18 +- .../src/client/office/FontNotice.module.css | 139 ++++++ .../src/client/office/FontNotice.tsx | 87 ++++ .../src/client/office/cache.ts | 179 ++++++++ .../src/client/office/index.ts | 96 +++++ .../src/client/office/locales.ts | 40 ++ .../src/client/pdf/PdfBody.module.css | 83 +++- .../src/client/pdf/document.ts | 26 +- .../src/client/pdf/pdf.tsx | 20 +- .../src/client/pdf/text.ts | 37 ++ .../src/client/rpc.ts | 20 +- .../src/client/store.ts | 9 +- .../ui-sidebar-documentpreview/src/config.ts | 27 ++ .../ui-sidebar-documentpreview/src/index.ts | 19 +- .../tests/apply.client.spec.ts | 17 +- .../tests/config.host.spec.ts | 29 ++ .../tests/custom-reader.client.spec.ts | 100 +++++ .../tests/document-bytes.client.spec.ts | 31 ++ .../tests/document-seat.client.spec.tsx | 1 + .../tests/document-toolbar.client.spec.tsx | 112 ++++- .../tests/face.client.spec.ts | 33 +- .../tests/fixtures.client.ts | 13 +- .../tests/fixtures/office-cache.patch.yml | 4 + .../tests/html-apply.client.spec.ts | 2 +- .../tests/image-registration.client.spec.ts | 2 +- .../tests/office-cache.client.spec.ts | 394 ++++++++++++++++++ .../tests/office-font-notice.client.spec.tsx | 92 ++++ .../tests/office-registration.client.spec.ts | 211 ++++++++++ .../tests/pdf-body.client.spec.tsx | 25 ++ .../tests/pdf-document.client.spec.ts | 27 ++ .../tests/pdf-fixture.ts | 42 +- .../tests/pdf-text.client.spec.ts | 49 +++ .../tests/text-preview.client.spec.tsx | 10 +- .../tsconfig.client.json | 72 ++++ .../tsconfig.host.json | 23 + .../ui-sidebar-documentpreview/tsconfig.json | 52 +-- .../ui-theme/src/styles/design-platform.css | 4 + .../src/client/slot-catalog.ts | 49 ++- .../extensions/tool-cordis/src/api-catalog.ts | 23 + .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../src/assembly/remote-proxies.ts | 3 + .../src/assembly/test-client.ts | 18 +- .../assembly-remote-proxies.client.spec.ts | 11 + .../assembly-test-client-node.client.spec.ts | 11 +- pnpm-lock.yaml | 86 ++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 6 +- scripts/package-dependency-policy.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + .../web/document-preview/document.expected.md | 17 + .../web/document-preview/paging.patch.yml | 6 +- tsconfig.base.json | 3 + tsconfig.client.json | 3 +- tsconfig.host.json | 3 + 133 files changed, 4118 insertions(+), 276 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml create mode 100644 .agents/notes/archived/architecture/2026-09-10-local-office-preview.md create mode 100644 .agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md create mode 100644 .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml create mode 100644 .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md create mode 100644 .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md create mode 100644 .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml create mode 100644 .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md create mode 100644 .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md create mode 100644 .agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md create mode 100644 apps/web/tests/expected/office-font-notice.md create mode 100644 apps/web/tests/fixtures/office/README.i18n.yaml create mode 100644 apps/web/tests/fixtures/office/README.md create mode 100644 apps/web/tests/fixtures/office/README.zh.md create mode 100644 apps/web/tests/fixtures/office/preview.doc create mode 100644 apps/web/tests/fixtures/office/preview.ppt create mode 100644 apps/web/tests/fixtures/office/preview.xls create mode 100644 apps/web/tests/office-fixture.ts create mode 100644 packages/api/document-render-controller/README.i18n.yaml create mode 100644 packages/api/document-render-controller/README.md create mode 100644 packages/api/document-render-controller/README.zh.md create mode 100644 packages/api/document-render-controller/package.json create mode 100644 packages/api/document-render-controller/src/client/index.ts create mode 100644 packages/api/document-render-controller/src/index.ts create mode 100644 packages/api/document-render-controller/src/types.ts create mode 100644 packages/api/document-render-controller/tests/apply.client.spec.ts create mode 100644 packages/api/document-render-controller/tests/controller.spec.ts create mode 100644 packages/api/document-render-controller/tsconfig.client.json create mode 100644 packages/api/document-render-controller/tsconfig.host.json create mode 100644 packages/api/document-render-controller/tsconfig.json create mode 100644 packages/api/document-render-controller/tsdown.config.ts create mode 100644 packages/bundle/web-app/tests/document-preview.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/index.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/config.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/custom-reader.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml create mode 100644 packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/pdf-text.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tsconfig.client.json create mode 100644 packages/client/ui-sidebar-documentpreview/tsconfig.host.json diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml new file mode 100644 index 0000000000..0afd4836d9 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-local-office-preview.md +2026-09-10-local-office-preview.md: ea32e37f0b2dcba866b78947c785dcee8e475b29 +2026-09-10-local-office-preview.zh.md: a1ac8337a1cf22f5842485c0790c6554635dc125 diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md new file mode 100644 index 0000000000..ea32e37f0b --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md @@ -0,0 +1,52 @@ +# Agent Note: Local Office preview through WASM PDF bytes + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-10-local-office-preview.zh.md) + +## Problem + +Office Open XML files are ZIP archives, so text fallback cannot provide a useful preview. Document conversion must stay local without taking focus or adding document content to a model conversation. Engine success alone cannot prove that the input has the claimed format or that a PDF was produced. + +## Decision + +A [document-render Service Definition](../../../../packages/document/document-render/README.md), a LibreOffice WASM provider, and Client/API consumers form the conversion capability. [Boot composition](../../../../packages/document/document-render-auto/README.md) mounts the provider and Remote controller only when `wasm.artifactDirectory` is configured. Invalid explicit configuration fails initialization. The Office Client registration always exists so an unconfigured Host receives guidance. Availability follows the generated Remote namespace without executable discovery or a duplicate Client flag. + +The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) uses pinned official LibreOffice source, Emscripten, and a LibreOfficeKit adapter. Each Node Worker owns one engine instance, its pthreads, and its memory filesystem. Terminating the Worker cancels synchronous engine calls; every conversion waits for its Worker to stop before returning. OOXML inspection verifies archive membership, content type, and resource bounds before startup. + +A VCL callback requests a font family before substitution or missing Unicode characters during glyph fallback. Host code indexes installed font metadata and copies selected files into engine memory, including complete font collections. Exact installed families precede configured alternatives; original-first fontconfig aliases preserve that ordering inside LibreOffice. Optional initial families use the same resolver. The engine has no Host filesystem mount, and rendering neither installs nor downloads fonts. The [build recipe](../../../../native/libreoffice-wasm/README.md) records source and toolchain revisions, patches, and asset hashes. Engine bundles remain immutable during provider use; a different build uses a new directory. + +The [independent engine release](../process/2026-09-11-independent-libreoffice-package.md) owns precompiled npm packaging and keeps engine compilation outside ordinary DSH builds. + +The provider returns caller-owned PDF bytes and independent `succeeded`, `timedOut`, and `cancelled` facts. PDF bytes are copied out of engine memory before teardown. The API consumer authorizes the source through [Workspace Files](2026-09-09-workspace-file-read-authority.md), requires successful uninterrupted conversion, and encodes the PDF directly. Source path and freshness version survive the PDF transport. Workspace Files limits source reads; the provider's `maxOutputBytes` alone limits generated PDFs. No temporary PDF or file lease is needed. + +The Office Client watches the selected Conversation's existing Deliverables projection and warms recent Office files without opening tabs. Its bounded memory cache checks authorized source metadata on every read and shares pending conversions between background and foreground callers. Only the last departing reader cancels a shared conversion; failures are not cached and connection reset discards cached bytes. [Document Preview](2026-09-08-document-preview-operations.md) owns format selection, loading, cancellation, and the PDF.js Worker. + +PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. + +Missing-family observations accompany the PDF through the worker and Remote response. The Office plugin renders a keyed notice above the shared PDF scrollport, so dismissing it removes its layout height. The font resolver intersects explicit source and theme references with actual document requests, excluding engine defaults and font-table inventories; installed aliases and glyph fallback do not by themselves mean a family is absent. + +## Alternatives considered + +**Keep an installed native LibreOffice provider.** A second provider adds executable discovery, version queries, subprocess supervision, scratch files, and platform-specific confinement. This design uses WASM for all supported Office previews. A native provider needs a demonstrated rendering or deployment requirement that WASM cannot satisfy. + +**Return a temporary PDF path.** The Worker already supplies independent PDF bytes. Writing them to disk, authorizing another file read, and maintaining leases adds file ownership without a current consumer. Source authorization and output limits apply directly to the memory result. + +**Automate installed Microsoft Office.** Word for Mac requires GUI automation rather than headless conversion, introducing focus changes and per-file operating-system authorization. Native Office automation remains outside this preview capability. + +**Use an online converter or download the engine automatically.** Remote conversion uploads document content. Automatic installation adds runtime distribution and update responsibilities; explicit artifact configuration leaves deployment with the operator. + +**Convert PDF pages to PNG.** PDF.js already owns PDF presentation. Rasterizing adds another image pipeline and loses the existing PDF controls and selectable text. + +**Add a model-facing rendering tool or durable preview events.** Client preview does not supply model input. A model-facing tool requires logged facts and both SDK projections, so it is a separate consumer. + +**Scan every font-table entry for warnings.** Font tables include unused styles and templates. Reporting them would make the notice unrelated to rendered content; collection instead starts after engine initialization and ends after PDF save. + +## Consequences + +The single-provider design assumes WASM covers the required Office rendering behavior; it is not evidence of complete fidelity equivalence with native LibreOffice. Fidelity depends on the engine build and installed fonts. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) cover DOCX, XLSX, PPTX, selectable Chinese text, and embedded system fonts; ABI fixtures alone cannot establish rendering fidelity or assembled browser presentation. + +WASM instances have substantial memory and startup costs, so the provider bounds concurrency. The [font reuse and image resolution decision](2026-09-11-wasm-preview-font-and-image-budgets.md) owns shared Host metadata, Worker request memoization, and raster export limits; only selected fonts enter engine memory. Large collections require bounded memory growth, and CFF export requires checked stack space. A fatal abort prevents further calls into C++. A device without a font covering a character cannot render it correctly without additional fonts. Owning the WASM build adds compiler compatibility, patch maintenance, and redistribution obligations. + +Prewarming consumes conversion resources to reduce foreground waits; entry and byte limits bound retained Client results. Source and PDF bytes remain outside Session storage and persistent caches. The [provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover independent output facts, input/output rejection, queued cancellation, and active Worker termination. Existing preview registry, file read authority, and Session ownership decisions remain active. diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md new file mode 100644 index 0000000000..a1ac8337a1 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 通过 WASM PDF 字节进行本地 Office 预览 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-10-local-office-preview.md) | 中文 + +## 问题 + +Office Open XML 文件是 ZIP 归档,因此文本回退无法提供有用的预览。文档转换必须留在本地,不抢占焦点,也不将文档内容放入模型对话。仅凭引擎成功状态无法证明输入具有声明的格式或生成了 PDF。 + +## 决定 + +[document-render Service Definition](../../../../packages/document/document-render/README.zh.md)、LibreOffice WASM 提供方及 Client/API 消费方构成转换能力。[启动组合](../../../../packages/document/document-render-auto/README.zh.md)仅在配置 `wasm.artifactDirectory` 时挂载提供方和 Remote 控制器。无效的显式配置会使初始化失败。Office Client 注册始终存在,为未配置的 Host 提供指引。可用性由生成的 Remote 命名空间决定,不发现可执行文件,也不维护重复的 Client 标记。 + +[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)使用固定的官方 LibreOffice 源码、Emscripten 和 LibreOfficeKit 适配器。每个 Node Worker 拥有一个引擎实例、其 pthread 和内存文件系统。终止 Worker 可取消同步引擎调用;每次转换均在返回前等待 Worker 停止。OOXML 检查在启动前验证归档成员、内容类型和资源限制。 + +VCL 回调在替换前请求字体族,或在字形回退时请求缺失的 Unicode 字符。Host 代码索引已安装字体元数据,将选中的文件复制到引擎内存,包括完整字体集合。精确匹配的已安装字体优先于配置的替代项;原字体优先的 fontconfig 别名在 LibreOffice 内保留此顺序。可选的初始字体族使用同一解析器。引擎不挂载 Host 文件系统,渲染不安装或下载字体。[构建配方](../../../../native/libreoffice-wasm/README.zh.md)记录源码与工具链版本、补丁及产物哈希。提供方使用期间,引擎包保持不可变;不同构建使用新目录。 + +[独立引擎发布](../process/2026-09-11-independent-libreoffice-package.zh.md)负责预编译 npm 打包,并将引擎编译与普通 DSH 构建隔离。 + +提供方返回调用方拥有的 PDF 字节,以及独立的 `succeeded`、`timedOut` 和 `cancelled` 事实。PDF 字节在清理前从引擎内存复制出来。API 消费方通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件,要求转换成功且未被中断,并直接编码 PDF。源路径和新鲜度版本保留在 PDF 传输中。Workspace Files 限制源文件读取;生成 PDF 仅受提供方的 `maxOutputBytes` 限制。无需临时 PDF 或文件租约。 + +Office Client 观察所选 Conversation 现有的 Deliverables 投影,在不打开 tab 的情况下预转换最近的 Office 文件。有上限的内存缓存每次读取都检查已授权的源文件元数据,并在后台和前台调用方之间共享进行中的转换。仅最后一个退出的读取方取消共享转换;失败不缓存,连接重置丢弃缓存字节。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责格式选择、加载、取消和 PDF.js Worker。 + +PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 + +缺失字体族观察值随 PDF 经过 Worker 和 Remote 响应。Office 插件在共享 PDF 滚动区域上方呈现 keyed 提示,关闭时移除其布局高度。字体解析器取源文档及主题显式引用与实际文档请求的交集,排除引擎默认字体和字体表清单;已安装别名和字形回退本身不代表字体族缺失。 + +## 考虑过的替代方案 + +**保留已安装的原生 LibreOffice 提供方。** 第二个提供方增加可执行文件发现、版本查询、子进程监督、临时文件和平台特定隔离。本设计将 WASM 用于全部受支持的 Office 预览。引入原生提供方需要证明存在 WASM 无法满足的渲染或部署需求。 + +**返回临时 PDF 路径。** Worker 已提供独立的 PDF 字节。将其写入磁盘、授权另一处文件读取并维护租约,会增加文件所有权,而没有当前消费方需要这些操作。源文件授权和输出限制直接应用于内存结果。 + +**自动操作已安装的 Microsoft Office。** Word for Mac 需要 GUI 自动化而非 headless 转换,会引入焦点变化和逐文件操作系统授权。原生 Office 自动化不属于本预览能力。 + +**使用在线转换器或自动下载引擎。** 远程转换会上传文档内容。自动安装增加运行时分发和更新责任;显式产物配置将部署留给运维者。 + +**将 PDF 页面转换为 PNG。** PDF.js 已负责 PDF 展示。栅格化增加另一条图像流水线,并失去现有 PDF 控件和可选择文本。 + +**添加面向模型的渲染工具或持久化预览事件。** Client 预览不提供模型输入。面向模型的工具需要记录事实并更新两种 SDK 投影,因此属于独立消费方。 + +**扫描全部字体表条目生成提示。** 字体表包含未使用的样式和模板。报告这些条目会使提示与实际渲染内容无关,因此收集从引擎初始化完成后开始,在 PDF 保存后结束。 + +## 影响 + +单一提供方设计假设 WASM 覆盖所需的 Office 渲染行为;这不是与原生 LibreOffice 完全等价的保真度证据。保真度取决于引擎构建和已安装字体。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)覆盖 DOCX、XLSX、PPTX、可选择中文文本和嵌入系统字体;仅有 ABI fixture 无法证明渲染保真度或完整浏览器展示。 + +WASM 实例有较高的内存与启动成本,因此提供方限制并发。[字体复用与图像分辨率决策](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)负责共享 Host 元数据、Worker 请求记忆化和栅格导出限制;仅选中字体进入引擎内存。大型集合需要有界内存增长,CFF 导出需要受检查的栈空间。致命 abort 后不再调用 C++。设备没有覆盖某字符的字体时,无法在不添加字体的前提下正确渲染该字符。自行构建 WASM 会增加编译器兼容、补丁维护和再分发义务。 + +预转换消耗转换资源以减少前台等待;条目数和字节限制约束 Client 保留的结果。源文件和 PDF 字节不进入 Session 存储或持久缓存。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖独立输出事实、输入/输出拒绝、排队取消和活跃 Worker 终止。现有预览注册表、文件读权限和 Session 所有权决策保持有效。 diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml new file mode 100644 index 0000000000..5e3c3d687d --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md +2026-09-11-wasm-preview-font-and-image-budgets.md: ab33b0bb6e28e5749ac38f36c08bc7539a368515 +2026-09-11-wasm-preview-font-and-image-budgets.zh.md: e6ff458e8b68ce08ff5d81ce6767a08a82974a74 diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md new file mode 100644 index 0000000000..ab33b0bb6e --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md @@ -0,0 +1,36 @@ +# Agent Note: Font reuse and image resolution in WASM previews + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-11-wasm-preview-font-and-image-budgets.zh.md) + +## Problem + +Office conversion repeats font metadata parsing across documents and font matching within one document. Image-heavy documents also spend substantial PDF export time resampling images above preview resolution. These costs need separate controls because font reuse does not reduce image decoding or resampling. + +## Decision + +The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) builds a font metadata snapshot once during initialization. Each conversion Worker receives a structured clone. The Host retains names, face attributes, paths, sizes, and modification times; glyph coverage, request caches, and imported bytes belong to the Worker. Reloading the provider refreshes the snapshot. Workers validate indexed files when reading them, and an already imported font remains usable within that conversion. + +Each Worker memoizes complete requests: family, style, weight, italic, width, pitch, language, and ordered code points. The cache stores MEMFS paths and missing-family observations. Hits replay those observations because an initialization request can recur after document font collection starts. Worker termination releases both the cache and engine memory. + +Runtime PDF filter options reduce raster images to a configurable `maxImageResolution`, defaulting to 192 DPI. The [shared PDF canvas](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts) renders at 96 CSS DPI times the device pixel ratio; the default covers ratio 2. Text and vector graphics remain scalable. Explicit bookmark export preserves LibreOfficeKit's default when JSON filter options replace its implicit filter data. + +The [local Office preview decision](2026-09-10-local-office-preview.md) continues to own conversion lifetime, authorization, missing-font presentation, and PDF transport. + +## Alternatives considered + +**Index fonts inside every conversion Worker.** This keeps discovery off the Host event loop and sees newly installed fonts immediately, but repeats full-file metadata parsing across previews. A provider snapshot removes that repeated work at the cost of synchronous initialization and explicit reload after font changes. + +**Cache only the family name.** Style, language, pitch, and missing characters can select different files. A complete request key retains those distinctions and still captures repeated layout requests. + +**Keep 300 DPI for every preview.** Higher raster resolution retains detail for high pixel ratios and magnification but increases image export work. The configurable 192-DPI default follows the common display target without rasterizing text or rebuilding the engine. + +## Consequences + +Cold provider startup includes indexing and remains separate from conversion deadlines. Shared metadata retains no original font buffers, while each Worker still reads selected fonts and lazily decodes coverage. Installing or replacing fonts requires provider reload; a file changed since indexing can fail a later conversion. + +Reducing image resolution trades raster detail at high magnification for less export work. It is not an engine memory cap: LibreOffice can still decode full-resolution source images, load large font collections, or exhaust its build-time WASM memory maximum. No persistent font cache, filesystem watcher, or cross-document engine instance is introduced. + +[Font tests](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts) cover complete request keys, repeated missing-family observations, snapshot isolation, and failed imports. [Provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover snapshot reuse and reload. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) inspect exported image dimensions with default and overridden DPI settings alongside selectable text and page count; ABI forwarding alone cannot establish filter behavior. diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md new file mode 100644 index 0000000000..e6ff458e8b --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md @@ -0,0 +1,36 @@ +# Agent Note: WASM 预览的字体复用与图像分辨率 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-11-wasm-preview-font-and-image-budgets.md) | 中文 + +## 问题 + +Office 转换在不同文档间重复解析字体元数据,并在同一文档内重复匹配字体。图片较多的文档还会在 PDF 导出时花费大量时间,重采样高于预览显示分辨率的图像。这些成本需要分别控制,因为字体复用不会减少图像解码或重采样。 + +## 决策 + +[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)在初始化时建立一次字体元数据快照。每个转换 Worker 接收结构化克隆。Host 保留名称、字体面属性、路径、大小和修改时间;字形覆盖、请求缓存及导入字节属于 Worker。重新加载提供方会刷新快照。Worker 读取已索引文件时校验文件,已经导入的字体在该次转换内仍可使用。 + +每个 Worker 记忆化完整请求:字体族、样式、字重、斜体、字宽、字距类型、语言及有序码点。缓存保存 MEMFS 路径和缺失字体族观察值。命中时重放这些观察值,因为初始化请求可能在文档字体收集开始后再次出现。Worker 终止时释放缓存及引擎内存。 + +运行时 PDF 过滤器选项将栅格图像降低到可配置的 `maxImageResolution`,默认 192 DPI。[共享 PDF 画布](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts)按 96 CSS DPI 乘以设备像素比渲染;默认值覆盖像素比 2。文本和矢量图形仍可缩放。JSON 过滤器选项替换隐含过滤器数据时,显式导出书签保留 LibreOfficeKit 的默认行为。 + +[本地 Office 预览决策](2026-09-10-local-office-preview.zh.md)继续负责转换生命周期、授权、缺失字体展示和 PDF 传输。 + +## 考虑过的替代方案 + +**在每个转换 Worker 内索引字体。** 这会将发现工作移出 Host 事件循环,并立即识别新安装的字体,但会在不同预览间重复读取完整字体文件并解析元数据。提供方快照消除重复工作,代价是同步初始化,以及字体变化后显式重新加载。 + +**只缓存字体族名称。** 样式、语言、字距类型和缺失字符可能选择不同文件。完整请求键保留这些区别,同时仍能命中重复排版请求。 + +**所有预览保持 300 DPI。** 更高栅格分辨率保留高像素比及放大时的细节,但增加图像导出工作。可配置的 192 DPI 默认值符合常见显示目标,无需栅格化文本或重新构建引擎。 + +## 影响 + +提供方冷启动包含索引成本,不计入转换时限。共享元数据不保留原始字体缓冲区,各 Worker 仍会读取选中字体并按需解码字形覆盖。安装或替换字体后需要重新加载提供方;索引后发生变化的文件可能使后续转换失败。 + +降低图像分辨率以高倍缩放下的栅格细节换取更少的导出工作。它不是引擎内存上限:LibreOffice 仍可能解码全分辨率原图、加载大型字体集合,或耗尽构建时设定的 WASM 内存上限。此实现不引入持久化字体缓存、文件系统监听器或跨文档引擎实例。 + +[字体测试](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts)覆盖完整请求键、重复缺失字体族观察值、快照隔离和失败导入。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖快照复用和重新加载。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)检查默认及覆盖 DPI 设置时的导出图像尺寸、可选择文本和页数;仅验证 ABI 参数转发无法证明过滤器行为。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 0d758eec58..2ee3fa8226 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -364,6 +364,12 @@ "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30", "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371", "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1", + "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8", + "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763", + "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7", @@ -1609,6 +1615,9 @@ "process/2026-09-03-workspace-version-coherence-gate.i18n.yaml": "sha256:ac442dee172d396395b8516d6d38c4fcedbc855d735c86c378aec5c1098ecd8d", "process/2026-09-03-workspace-version-coherence-gate.md": "sha256:37b63a78506a8741eb04826dc1eea7e3a64c19fd4494c2d7754054a5602d308b", "process/2026-09-03-workspace-version-coherence-gate.zh.md": "sha256:cfa2c8fe6cf1d4665121a987861b70a91b5b41eb99e7ed192c85760a916fddf6", + "process/2026-09-11-independent-libreoffice-package.i18n.yaml": "sha256:e8d2fe75e05ac53438513a7c51dab6510bcecf644019391ee6650e73961c0f8d", + "process/2026-09-11-independent-libreoffice-package.md": "sha256:1292b409b7b4cbc4420868de5e3b7417b3c855889023ccfd9a61d824bf3019d8", + "process/2026-09-11-independent-libreoffice-package.zh.md": "sha256:384783da1614c7e2ea84ea513013af6b20e9d4545a03cf7fa068415078126932", "simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml": "sha256:43fe5daadc1491f94a3e34595182cad1591f76b64e1f13b48811b80fd7e53b94", "simplification/2026-06-19-drop-mutable-session-summary.md": "sha256:01647a5a14aa4e195328d4d39c6d80eb0723739e5a543115314716b280a93560", "simplification/2026-06-19-drop-mutable-session-summary.zh.md": "sha256:22389e0c29158b1f7a5a43ed6073f8795bb87cf51c04cf83be055359cd65c9f5", diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml new file mode 100644 index 0000000000..7f17afb0a9 --- /dev/null +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-independent-libreoffice-package.md +2026-09-11-independent-libreoffice-package.md: 05fd8eb86ffb75b564008eaffda5c98669afee27 +2026-09-11-independent-libreoffice-package.zh.md: b2abf61f8a5cf9b247fa2b94697eb90a252d4cff diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md new file mode 100644 index 0000000000..05fd8eb86f --- /dev/null +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md @@ -0,0 +1,32 @@ +# Agent Note: Independent precompiled LibreOffice package + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-11-independent-libreoffice-package.zh.md) + +## Problem + +Compiling LibreOffice inside ordinary DSH builds would require every contributor and CI job to acquire its toolchain and repeat a large native build. Desktop also needs immutable engine resources that can travel with its offline installation material. + +## Decision + +The [engine package](../../../../native/libreoffice-wasm/package.json) has its own version and an asset-only manifest export. Its source directory stays outside the main pnpm workspace. Installation has no lifecycle hook, and packaging verifies an existing successful build without compiling. The [release workflow](../../../../.github/workflows/libreoffice-wasm-release.yml) compiles only on explicit dispatch; publication requires a matching engine version tag and the protected npm environment. + +Each tarball contains the engine manifest and assets, corresponding source pins, patches, full source diff, and the built distribution's license and notices. Packaging rejects mismatched build receipts, modified assets, unlisted engine files, missing notices, and bundled fonts. A failure never triggers compilation automatically. + +The [local preview decision](../architecture/2026-09-10-local-office-preview.md) continues to own conversion and explicit artifact configuration. This change establishes release machinery; it does not add an unpublished engine dependency to DSH. The existing [Desktop package set](../../../../apps/desktop/scripts/prepare-package-set.ts) remains the intended carrier for a future fixed-version dependency and its offline seed. + +## Alternatives considered + +**Compile during ordinary builds or package installation.** This puts engine toolchain availability and compilation cost on every consumer, even when the engine version has not changed. + +**Extract a general Node conversion library.** Font handling, Worker ownership, and the conversion API can remain with the provider. Moving them is unnecessary to distribute precompiled assets or isolate compilation from DSH CI. + +**Link the engine source as a workspace dependency.** Workspace linking would replace a published precompiled package with a source directory that lacks its engine files. + +## Consequences + +The engine can be compiled and versioned independently of DSH. The package exposes files rather than a conversion API; the provider retains runtime ownership. Ordinary builds remain free of LibreOffice compilation, as checked by the [packaging tests](../../../../scripts/libreoffice-package.spec.ts). + +Initial publication and fixed-version consumer integration remain incomplete. A source tree that differs from its recorded recipe cannot produce a release tarball. Real-engine conversion, installation from the packed package, and Desktop offline seed qualification must accompany the first consumed engine release. diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md new file mode 100644 index 0000000000..b2abf61f8a --- /dev/null +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 独立的预编译 LibreOffice 包 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-11-independent-libreoffice-package.md) | 中文 + +## 问题 + +在普通 DSH 构建中编译 LibreOffice,会要求每个贡献者和 CI job 获取工具链并重复执行大型本机构建。Desktop 也需要能随离线安装材料分发的不可变引擎资源。 + +## 决策 + +[引擎包](../../../../native/libreoffice-wasm/package.json)采用独立版本,只导出产物清单。源码目录不属于主 pnpm workspace。安装没有生命周期 hook,打包只校验已有的成功构建,不执行编译。[发布工作流](../../../../.github/workflows/libreoffice-wasm-release.yml)仅在明确调度时编译;发布要求匹配的引擎版本 tag 和受保护的 npm 环境。 + +每个 tarball 包含引擎清单和产物、对应的源码版本、补丁、完整源码差异,以及构建发行物的许可证和声明。打包拒绝不匹配的构建记录、被修改的产物、未列入清单的引擎文件、缺失的声明和内置字体。失败不会自动触发编译。 + +[本地预览决策](../architecture/2026-09-10-local-office-preview.zh.md)继续负责转换与显式产物配置。此改动建立发布机制,不向 DSH 添加尚未发布的引擎依赖。现有 [Desktop 包集合](../../../../apps/desktop/scripts/prepare-package-set.ts)仍是未来固定版本依赖及其离线 seed 的预期载体。 + +## 考虑过的替代方案 + +**在普通构建或包安装期间编译。** 即使引擎版本没有变化,每个消费方也要承担工具链可用性要求和编译开销。 + +**抽出通用 Node 转换库。** 字体处理、Worker 所有权和转换 API 可以继续由提供方负责。分发预编译产物及将编译与 DSH CI 隔离不需要迁移这些逻辑。 + +**将引擎源码链接为 workspace 依赖。** 工作区链接会把已发布的预编译包替换为缺少引擎文件的源码目录。 + +## 影响 + +引擎可以独立于 DSH 编译和发布版本。包暴露文件而非转换 API;提供方保留运行时所有权。[打包测试](../../../../scripts/libreoffice-package.spec.ts)检查普通构建不编译 LibreOffice。 + +首次发布和固定版本消费方接入尚未完成。与记录的配方不一致的源码树不能产生发布 tarball。首个被消费的引擎发行版必须同时完成真实引擎转换、打包安装,以及 Desktop 离线 seed 验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 511e949e4c..ecd1202efa 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793 -2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919 +2026-09-08-document-preview-operations.md: 8784db5917c474c7dff03c0943bbd644c69da3ec +2026-09-08-document-preview-operations.zh.md: 6c38d07f877acd24d1787c808abd4334657c69f5 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 43cc8d9357..8784db5917 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -16,10 +16,12 @@ Document Preview separates resource observation from content reads. The [resourc Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Complete-byte loading does not imply text compatibility: binary formats omit plain text, while HTML and SVG explicitly permit source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. + ## Alternatives considered **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index 44eccba894..6c38d07f87 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -16,10 +16,12 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。完整字节加载不代表文本兼容:二进制格式不提供纯文本,而 HTML 和 SVG 显式允许查看源码。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 + ## 考虑过的替代方案 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml new file mode 100644 index 0000000000..6885a149ee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +2026-09-11-node-office-kit.md: 35d28e0a136898af36af473285bf8f29dcf40145 +2026-09-11-node-office-kit.zh.md: 155342addce706d0a97148afd9f322153e25db32 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md new file mode 100644 index 0000000000..35d28e0a13 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -0,0 +1,65 @@ +# Agent Note: Node Office conversion with independently packaged engines + +Status: implemented + +English | [中文](2026-09-11-node-office-kit.zh.md) + +## Problem + +Binary Office and OOXML files require document layout before they can be previewed. Conversion must stay on the device without opening an Office application or adding source bytes to a model conversation. Native engines need platform-specific distribution, while browser conversion duplicates font transport, worker ownership, and resource limits across the Host and Client. + +## Decision + +The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime. + +The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-rendering.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. + +The service, Remote controller, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [Remote controller](../../../../packages/api/document-render-controller/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. Preview bytes do not enter Session storage or persistent caches. + +A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. + +The kit owns default serif, sans-serif, and monospace preference groups, including Chinese text families. Missing Chinese glyphs in Western text try the corresponding common text families before searching the remaining catalog, which avoids choosing a handwriting face solely because its file sorts first. The provider's optional `fontFallbacks` replaces these ordered groups without duplicating their defaults. Preferences preserve exact installed fonts and retain other covering fonts as a last resort; they are not a font whitelist. The native adapter writes missing-family choices into its private VCL profile. Installed metric-compatible fonts can resolve before that table, and platform glyph fallback remains available. Native platform selection and whole collection imports require inspecting the fonts actually used in exported PDFs. + +DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine. + +The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the shared scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. + +Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. + +The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion. + +Desktop installs the kit through its existing target-Node pnpm dependency installation and retains the complete dependency tree. Worker paths and executable permissions remain ordinary package files. The [Desktop build guide](../../../../apps/desktop/README.md) owns target selection and packaging; each signed application requires qualification on its target platform. + +The [Python executable distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) keeps the kit, target engine, and their dependency closure beside the executable. Its installed-wheel smoke relocates the payload, requires exactly the target backend, and converts DOCX once through that engine. Platform packaging and publication constraints belong to the [platform engine decision](2026-09-15-platform-office-engines.md). + +The notices gate permits only the exact API and engine package names at MPL-2.0 and continues to reject unrelated MPL or changed non-permissive terms. Each recipient must retain access to the kit’s corresponding LibreOffice source pin, patches, build instructions, and license notices; the engine packages retain their third-party notices. [MPL source availability](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) applies when distributing covered executables outside the organization. + +The [browser-only preview](../../../../packages/experimental/webworker-runtime/README.md) replaces the kit entry with an unavailable converter and excludes its engine dependency tree from the VFS image. Keeping the Host provider loadable preserves the ordinary Office error presentation without shipping Node Workers, native helpers, or WASM conversion resources to the browser. + +## Alternatives considered + +**Keep conversion in browser Workers.** This requires shipping the engine to the Client, browser font RPC, and shared-memory response headers. Node already owns authorized disk access and can serve the resulting PDF to every Client. + +**Use the installed soffice CLI or automate Microsoft Office.** Executable discovery and ambient versions weaken reproducibility; Office GUI automation additionally changes focus and requires application permissions. The distributed helper owns a fixed engine without requiring either application installation. + +**Load a native addon in the Host process.** A parser crash or synchronous stall would affect the Host. The separate native helper provides an independently terminable lifetime; its disk exchange also works with the WASM adapter. + +**Fall back after any native error.** Retrying a damaged package or failed document with another engine hides release defects, duplicates work, and makes output depend on failure timing. Platform selection requires the kit’s declared native target engine, or WASM when no native target is declared; it does not retry native failures with WASM. + +**Return a temporary PDF path, or rasterize pages to PNG.** The Client needs PDF bytes for its existing viewer and selectable text. Exposing temporary paths would add authorization and lease ownership; PNG would create another rendering pipeline and remove PDF controls. + +**Index every font for every render, or cache only family names.** Repeated indexing dominates small-document work, while family-only keys lose style and glyph distinctions. Shared metadata and complete per-request keys remove repeated parsing without retaining font buffers or introducing a filesystem watcher. + +**Compile at install time, download engines or fonts at runtime, or use an online converter.** These add toolchain or network requirements and may move private content off device. Prebuilt tarballs keep installation and conversion independent of those operations. + +**Keep an independent Office UI package.** Its reader, cache, and font notice have the same preview lifecycle and PDF presentation consumers. A separate plugin adds a package, boot wiring, and a Loader switch without an independently evolving UI responsibility. Host conversion and Remote authorization retain their separate plugins. + +**Remove the shared Client conversion cache.** Per-tab state cannot share pending conversion or retained PDFs across readers. A bounded cache avoids that repeated work while preserving per-reader cancellation and authorized version checks. + +**Add a model-facing rendering tool or durable preview events.** Preview provides no model input. Such a tool would require logged facts and both SDK projections and remains a separate consumer. + +## Consequences + +Native and WASM fidelity still depends on the build, source formatting, installed fonts, and platform font discovery. A missing glyph cannot be recovered without a covering font. Image resolution limits do not cap image decoding or total process memory. WASM retains bounded memory growth and checked stack space for large collections and CFF fonts; a fatal runtime abort prevents subsequent C++ cleanup calls. Macros and document-link updates are disabled by supported LOKit options and the pinned source patch; this does not constitute an OS sandbox. + +The [provider tests](../../../../packages/document/office-to-pdf/tests/provider.spec.ts), [Loader composition](../../../../packages/bundle/web-app/tests/document-preview.spec.ts), and [browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) own DSH lifecycle, authorization, and presentation evidence. Engine qualification additionally requires real DOC/DOCX/XLS/XLSX/PPT/PPTX conversion, external PDF text/font/page/image inspection, relocation and corrupted-package rejection, and same-input native/WASM performance samples. Mock helpers and microbenchmarks do not establish these outcomes. Each target's real builder and Desktop package need independent qualification; a successful local architecture does not prove the full matrix. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md new file mode 100644 index 0000000000..155342addc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 独立打包引擎的 Node Office 转换 + +Status: implemented + +[English](2026-09-11-node-office-kit.md) | 中文 + +## 问题 + +二进制 Office 和 OOXML 文件需要经过文档排版才能预览。转换必须留在设备上,不能打开 Office 应用,也不能将源字节加入模型对话。原生引擎需要按平台分发,而浏览器转换会在 Host 和 Client 两侧重复字体传输、worker 生命周期与资源限额管理。 + +## 决策 + +[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。 + +[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-rendering.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 + +服务、Remote 控制器和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[Remote 控制器](../../../../packages/api/document-render-controller/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。预览字节不会进入 Session 存储或持久缓存。 + +converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 + +kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中文正文字体。西文文本缺少中文字形时,先尝试同类的常用正文字体,再搜索其余字体目录,避免仅因文件排序靠前而选用手写体。provider 的可选 `fontFallbacks` 替换这些有序组,不重复维护默认值。优先规则保留已安装原字体的精确匹配,并以其他覆盖字体作为最后兜底;它们不是字体白名单。原生适配器将缺失字体的选择写入私有 VCL profile。已安装的度量兼容字体可能在查询该表前被选中,平台的字形回退仍然可用。原生平台的选择及完整字体集合的导入要求检查导出 PDF 实际使用的字体。 + +DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。 + +[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在共享滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 + +普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 + +[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 + +Desktop 通过现有的目标 Node pnpm 依赖安装流程安装 kit,并保留完整依赖树。Worker 路径和可执行权限仍由普通包文件承载。[Desktop 构建指南](../../../../apps/desktop/README.zh.md) 负责目标选择与打包;每个签名应用仍需在目标平台验收。 + +[Python 可执行分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)将 kit、目标引擎及其依赖闭包保留在可执行文件旁。安装后的 wheel 冒烟测试会迁移载荷,要求仅存在目标后端,并通过该引擎转换一次 DOCX。各平台的打包与发布限制由[平台引擎决策](2026-09-15-platform-office-engines.zh.md)说明。 + +声明检查仅放行精确的 API 与引擎包名及 MPL-2.0 条款,继续拒绝无关 MPL 包或变更后的非宽松条款。每位接收者都必须保有访问 kit 对应 LibreOffice 源码版本、补丁、构建说明与许可证声明的权限;引擎包保留各自的第三方声明。向组织外部分发受覆盖的可执行文件时,须满足 [MPL 源码可用性要求](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)。 + +[纯浏览器预览](../../../../packages/experimental/webworker-runtime/README.zh.md)用返回不可用错误的转换器替代 kit 入口,并从 VFS 镜像排除其引擎依赖树。保留 Host provider 的可加载性,可以沿用正常的 Office 错误展示,而无需向浏览器分发 Node Worker、原生辅助程序或 WASM 转换资源。 + +## 考虑过的替代方案 + +**保留浏览器 Worker 转换。** 这需要向 Client 分发引擎、提供浏览器字体 RPC 和配置共享内存响应头。Node 已经拥有授权磁盘访问能力,可以向所有 Client 提供转换后的 PDF。 + +**使用已安装的 soffice CLI 或自动化 Microsoft Office。** 可执行文件发现和环境中的版本削弱可复现性;Office GUI 自动化还会改变焦点并需要应用权限。随包辅助进程拥有固定引擎,不要求安装这两类应用。 + +**在 Host 进程中加载原生 addon。** 解析器崩溃或同步阻塞会影响 Host。独立的原生辅助进程具有可单独终止的生命周期,其磁盘交换方式也适用于 WASM 适配层。 + +**任何原生错误都触发回退。** 使用另一引擎重试损坏的包或失败文档会隐藏发布缺陷、重复计算,并使输出取决于失败时机。平台选择要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM;原生失败后不会使用 WASM 重试。 + +**返回临时 PDF 路径,或将页面栅格化为 PNG。** Client 需要 PDF 字节来使用现有查看器和可选择文本。暴露临时路径会增加授权与租约管理;PNG 则会建立另一条渲染管线并丢失 PDF 控件。 + +**每次渲染重新索引所有字体,或只按字体族缓存。** 重复索引主导小文档成本,而仅以字体族为键会丢失样式和字形差异。共享元数据与完整请求键减少重复解析,无需保留字体缓冲区或引入文件系统监听器。 + +**安装时编译、运行时下载引擎或字体,或使用在线转换器。** 这些方案增加工具链或网络要求,并可能将私有内容移出设备。预构建 tarball 使安装与转换不依赖这些操作。 + +**保留独立的 Office UI 包。** 其读取器、缓存和字体提示共享预览生命周期与 PDF 展示消费者。独立插件增加包、启动接线和 Loader 开关,却没有独立演进的 UI 职责。Host 转换与 Remote 授权仍保留独立插件。 + +**移除共享的 Client 转换缓存。** tab 内状态无法在读取方之间共享进行中的转换或已保留的 PDF。有界缓存减少这些重复工作,同时保留各读取方的取消和已授权版本检查。 + +**添加面向模型的渲染工具或持久预览事件。** 预览不会提供模型输入。这样的工具需要日志事实及两套 SDK 投影,仍属于独立消费者。 + +## 后果 + +原生与 WASM 的保真度仍取决于构建、源文件格式、已安装字体及平台字体发现。没有覆盖字体就无法恢复缺失字形。图片分辨率限额不限制图片解码或总进程内存。WASM 为大型字体集合和 CFF 字体保留有界内存增长与受检查的栈空间;致命运行时中止会阻止后续 C++ 清理调用。宏与文档链接更新由实际支持的 LOKit 选项和固定源码补丁禁用;这不构成操作系统沙箱。 + +[提供方测试](../../../../packages/document/office-to-pdf/tests/provider.spec.ts)、[Loader 组合](../../../../packages/bundle/web-app/tests/document-preview.spec.ts)和[浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts)负责 DSH 生命周期、授权与展示证据。引擎验收还需要真实 DOC/DOCX/XLS/XLSX/PPT/PPTX 转换、外部 PDF 文本、字体、页数与图片检查、迁移安装和损坏包拒绝,以及同输入的原生/WASM 性能样本。模拟辅助进程和微基准不能证明这些结果。各目标的真实构建机与 Desktop 安装包需要独立验收;一个本地架构成功不能证明整个矩阵。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 00fcf849e0..ff95742f46 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' -import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' +import { realOfficeBytes } from './office-fixture.ts' +import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -29,6 +30,24 @@ async function successShot(page: Page, name: string): Promise { await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) } +/** Exercise native browser selection and copy, including the text overlay's canvas alignment. */ +async function copyPdfText(page: Page, preview: Locator, expected: string): Promise { + const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first() + await text.waitFor({ state: 'visible' }) + await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text') + await text.click({ clickCount: 3 }) + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected) + await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => { + const canvas = node.querySelector('canvas')!.getBoundingClientRect() + const layer = node.querySelector('.textLayer')!.getBoundingClientRect() + return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height), + Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top)) + })).toBeLessThan(1) +} + /** Trigger the document owner's native scroll handler after a real first page overflows. */ async function scrollForNextPage(body: Locator): Promise { await body.evaluate((node) => { @@ -51,6 +70,13 @@ async function canvasColor(canvas: Locator): Promise { }) } +/** Select a workspace file through the Files tab and wait for its preview identity. */ +async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise { + await filesTab.click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() + await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) +} + describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser @@ -130,6 +156,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () '', ].join('')), writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), + writeFile(join(cwd, 'user-unit.pdf'), pdfFixture(2)), + writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), + ...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))), writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])), ]) @@ -171,12 +200,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) const preview = column.locator('[data-document-preview]') - const openFile = async (name: string): Promise => { - await filesTab.click() - await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() - await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) - } - // Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control. + const openFile = openPreviewFile.bind(undefined, column, filesTab, preview) const viewer = preview.locator('[data-document-viewer-menu]') const body = preview.locator('[data-textpreview-body]') const sections = ['# Document preview'] @@ -307,6 +331,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const restoredColor = await canvasColor(secondPage) expect(restoredColor).toBe('blue') expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId) + await copyPdfText(page, preview, 'Selectable PDF text') await successShot(page, 'pdf') sections.push([ '## PDF', '', @@ -316,8 +341,52 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, + '- Selected and copied text: Selectable PDF text', ].join('\n')) + await openFile('user-unit.pdf') + await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) + await copyPdfText(page, preview, 'Selectable PDF text') + sections.push('## PDF page units\n\n- UserUnit 2: selected and copied text aligns with the canvas') + + await openFile('selection.pdf') + await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) + const selectionLayer = preview.locator('.textLayer') + const selectionText = selectionLayer.locator('span:not(.markedContent)') + const titleText = selectionText.filter({ hasText: 'JOURNAL' }) + const priorityText = selectionText.filter({ hasText: 'HIGH / MEDIUM / LOW' }).first() + // The text resize observer aligns the overlay after the canvas becomes visible. + await titleText.waitFor({ state: 'visible' }) + await priorityText.waitFor({ state: 'visible' }) + const titleBox = await titleText.boundingBox() + const priorityBox = await priorityText.boundingBox() + if (titleBox === null || priorityBox === null) throw new Error('selection fixture text has no bounds') + const start = { x: titleBox.x + 1, y: titleBox.y + titleBox.height / 2 } + const end = { x: priorityBox.x + priorityBox.width / 2, y: priorityBox.y - 3 } + const drag = async (from: typeof start, to: typeof end, through?: typeof end): Promise => { + await page.mouse.move(from.x, from.y) + await page.mouse.down() + try { + if (through !== undefined) await page.mouse.move(through.x, through.y, { steps: 15 }) + await page.mouse.move(to.x, to.y, { steps: 15 }) + return await page.evaluate(() => window.getSelection()?.toString() ?? '') + } finally { await page.mouse.up() } + } + const priority = { x: end.x, y: priorityBox.y + priorityBox.height / 2 } + const forward = await drag(start, end, priority) + expect(forward).toContain('JOURNAL') + expect(forward).toContain('THREE TASKS') + expect(forward).not.toContain('REFLECTION') + expect(forward).not.toContain('AFTER TABLE') + const backward = await drag(priority, start) + expect(backward).toContain('THREE TASKS') + expect(backward).not.toContain('REFLECTION') + expect(backward).not.toContain('AFTER TABLE') + expect(await selectionLayer.locator('br').first().evaluate(node => getComputedStyle(node, '::selection').backgroundColor)) + .toBe('rgba(0, 0, 0, 0)') + await successShot(page, 'pdf-drag-selection') + sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent') + await openFile('tiny.png') const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) @@ -461,6 +530,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () `- Tail: ${completed.at(-1)}`, ].join('\n')) + const officeMenus: number[] = [] + const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.' + for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) { + await openFile(`unavailable.${extension}`) + expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) + await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) + expect(await preview.getByText('OFFICE_BINARY_PREVIEW', { exact: false }).count()).toBe(0) + officeMenus.push(await viewer.count()) + } + await successShot(page, 'office-unavailable') + sections.push([ + '## Office unavailable', '', + `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, + `- Guidance: ${configurationGuide}`, + '- Binary text shown: false', + '- Plain-text option and viewer picker: hidden', + ].join('\n')) + await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') await expect.poll(() => plainLines.count()).toBe(2) @@ -487,3 +575,134 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) }) }) + +describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('rejects renamed text and renders Chinese Office documents through the PDF worker', async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, + extraOverlayPath: fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + onTestFailed(async () => { + await saveFailureShot(page, `screenshots/0908-document-preview/office-${process.pid}`) + }) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd + if (cwd === undefined) throw new Error('settled Session has no workspace cwd') + await Promise.all([ + writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'), + writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')), + writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')), + writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')), + ...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))), + ...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')), + ]) + const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert') + try { + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await column.locator('[data-files-reload]').click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + const preview = column.locator('[data-document-preview]') + await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() + expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) + const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) + await canvas.waitFor({ state: 'visible', timeout: 60_000 }) + await expect.poll(() => canvas.evaluate((node) => { + const canvas = node as HTMLCanvasElement + const context = canvas.getContext('2d') + if (context === null) return false + const bytes = context.getImageData(0, 0, canvas.width, canvas.height).data + for (let index = 0; index < bytes.length; index += 4) { + if (bytes[index + 3] === 255 && bytes[index]! < 200 && bytes[index + 1]! < 200 && bytes[index + 2]! < 200) return true + } + return false + }), { timeout: 30_000 }).toBe(true) + const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name))) + expect(workerNames).toContain('dsh-pdf') + expect(workerNames.some(name => /libreoffice|soffice/i.test(name))).toBe(false) + await copyPdfText(page, preview, 'Office preview') + await copyPdfText(page, preview, '中文文档') + expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档') + expect(convert).toHaveBeenCalledTimes(1) + await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() + await canvas.waitFor({ state: 'visible' }) + expect(convert).toHaveBeenCalledTimes(1) + const notice = preview.locator('[data-office-font-notice]') + const more = notice.getByRole('button', { name: 'Show more', exact: true }) + await more.waitFor({ state: 'visible' }) + const before = await canvas.evaluate(node => node.getBoundingClientRect().top) + await more.click() + const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true }) + await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' }) + await successShot(page, 'office-font-details') + await page.keyboard.press('Escape') + await expect.poll(() => details.count()).toBe(0) + expect(await more.evaluate(node => node === document.activeElement)).toBe(true) + await more.click() + await page.getByRole('button', { name: 'Close font details', exact: true }).click() + expect(await more.isVisible()).toBe(true) + await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click() + await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0) + const after = await canvas.evaluate(node => node.getBoundingClientRect().top) + expect(before - after).toBeGreaterThan(40) + const topInset = await preview.evaluate((node) => { + const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect() + const canvas = node.querySelector('canvas')!.getBoundingClientRect() + return canvas.top - body.top + }) + expect(topInset).toBe(0) + await successShot(page, 'office-font-dismissed') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [ + '# Office font notice', '', + '- Requested absent family is listed: true', + '- Escape restores focus to Show more: true', + '- Closing details preserves the notice: true', + '- Dismissing the notice collapses its occupied height: 0', + `- Document top inset after dismissal: ${topInset}px`, + ].join('\n'), MODE) + await successShot(page, 'office-docx') + for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) { + await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) + await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) + await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') + await successShot(page, `office-${extension}`) + } + expect(convert).toHaveBeenCalledTimes(6) + await openPreviewFile(column, filesTab, preview, 'chinese.docx') + await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) + await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() + await expect.poll(() => convert.mock.calls.length).toBe(7) + await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) + await openPreviewFile(column, filesTab, preview, 'renamed.docx') + await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) + await successShot(page, 'office-invalid') + expect(convert).toHaveBeenCalledTimes(8) + for (const extension of ['doc', 'xls', 'ppt']) { + await openPreviewFile(column, filesTab, preview, `renamed.${extension}`) + await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) + } + expect(convert).toHaveBeenCalledTimes(11) + expect(tripwire.pageErrors).toEqual([]) + } finally { convert.mockRestore() } + }) +}) diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md new file mode 100644 index 0000000000..4de2908510 --- /dev/null +++ b/apps/web/tests/expected/office-font-notice.md @@ -0,0 +1,7 @@ +# Office font notice + +- Requested absent family is listed: true +- Escape restores focus to Show more: true +- Closing details preserves the notice: true +- Dismissing the notice collapses its occupied height: 0 +- Document top inset after dismissal: 0px diff --git a/apps/web/tests/fixtures/office/README.i18n.yaml b/apps/web/tests/fixtures/office/README.i18n.yaml new file mode 100644 index 0000000000..c997788ced --- /dev/null +++ b/apps/web/tests/fixtures/office/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/web/tests/fixtures/office/README.md +README.md: affa0b57b72414beb4b8e75f19481a92b8745d27 +README.zh.md: ff49b01c51754ae00c613a45ebc8d14f142da9c1 diff --git a/apps/web/tests/fixtures/office/README.md b/apps/web/tests/fixtures/office/README.md new file mode 100644 index 0000000000..affa0b57b7 --- /dev/null +++ b/apps/web/tests/fixtures/office/README.md @@ -0,0 +1,7 @@ +# Binary Office fixtures + +English | [中文](README.zh.md) + +`preview.doc`, `preview.xls`, and `preview.ppt` contain `Office preview 中文文档`. They are the `one-page.doc`, `one-sheet.xls`, and `one-slide.ppt` fixtures from [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures), exported from Harness-authored OOXML with LibreOffice 26.8.0.3 using the Word, Excel, and PowerPoint 97 filters. The source documents retain the Harness MIT license. + +The browser regression reads these committed OLE files and verifies actual conversion and selectable PDF text. They contain no user documents and require no Office application or fixture generator during tests. They cover simple legacy imports, not complex-document fidelity. diff --git a/apps/web/tests/fixtures/office/README.zh.md b/apps/web/tests/fixtures/office/README.zh.md new file mode 100644 index 0000000000..ff49b01c51 --- /dev/null +++ b/apps/web/tests/fixtures/office/README.zh.md @@ -0,0 +1,7 @@ +# 二进制 Office 测试文件 + +[English](README.md) | 中文 + +`preview.doc`、`preview.xls` 和 `preview.ppt` 包含 `Office preview 中文文档`。它们来自 [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures) 的 `one-page.doc`、`one-sheet.xls` 和 `one-slide.ppt`,由 Harness 编写的 OOXML 通过 LibreOffice 26.8.0.3 的 Word、Excel 和 PowerPoint 97 过滤器导出。源文档保留 Harness 的 MIT 许可证。 + +浏览器回归读取这些检入的 OLE 文件,验证真实转换和可选取的 PDF 文字。文件不含用户文档,测试期间不需要 Office 应用或文件生成器。它们覆盖简单的旧格式导入,不代表复杂文档的保真度。 diff --git a/apps/web/tests/fixtures/office/preview.doc b/apps/web/tests/fixtures/office/preview.doc new file mode 100644 index 0000000000000000000000000000000000000000..91f9301f31aa25c2d5d36bdb60ce40d6dc607dc5 GIT binary patch literal 9216 zcmeI1TWnNC7{|Y}y=@UnX=_D5SxPOHOX)?RSkT^pve*k0t%_oLl`7q>ZBa;kFdBmo zn$-AUVobyYpELyZNni9u-i*eC2j1|>n3#yh7ZU0E`=7IiE^W8F6hpY|Y<@d)=FH6Z z%{SkfGc)JspO#(x^ve~$+KkX(SvD2Rx7>t$hxAhay3i~@T6t5kSj^8RnF+U%1+K(( z)fi?oh&iA!&x0W3!y>o`7Q+%K0F}oD-1s)?ZbsD^7C2y0eiIh5T^1o8vlEPJ`k@q$ zNo+Emnu+s8I^|Q9$;h9!Kq_15exm*T`TKgGf4v9v{4awdD25Wa7fRtiSPu8Y3RnrN z-~o`V)x@EAx{P=&l*2<%0qdX=9)|U>0XD)WsDjN<4K+{;b?^vmfvvC&>R~%P3JtIW zcEV%uI5a{NG(!ut!YTx;TceW55?o@ zk-9Zf4%%fg!AqOme7(`lzIXp^x5lr1xPL4<9vzwp^+d-8Hnm1yJ~lEKnQ-3;?cK_2 zjrO~qpmY@%?)|EI`)B)CuXqj3_mBU1LgyclUDm<; zHfAH%Ye%`(d#tA1)|Wd?xLchyueZ){w(WzP5mMg>x*@mK~gc!{J=( z4KKGB+P3R0nLO;asnw1J9QjZl9yEyD0UKs+eUY3cXjy#vl9il3Zn@~*778A+no`xmlh1JhiOSs0a|4!T?x5L6H2_u0-9D7 zl))oL5pNl?r*sl;NgWQ-oe@+T#~dBRuMz7tCl^pBx6n>HKG%)GN34&Ua-6z~^rSc8 zY{PM98%C0&88SuCK=q;N81?+Y2r;E7XV`|QovKgUBq@ay-EMI;lQgx=w3eG*J!u%R zW4K<(bt_L>=^8=HkUL#Aq}i4p$O%vnH6r8a(T#N-f8eEPl90?&emjZQZzxZ%Z*%F> z%;Vw&-(v>+0arOP%d}pGTvsnXho2s}w_F#wHrKDZF3Um`Qn5_90SgpW-$3OgRcFiw zA$O2cd*gL$^2TiWQ|z3X=V#rJ+gyD6lfN%_9x43%{h)1F_w@zIdmZFpy07((_GgWt zJ(|`hdcSGiu^0wG`?XP!gx{~2Js3}`tw!)eqn{v7mQE;gHTlP9j?*5>$EhzJ_|D7q z%o3m73#_F#dbBrE@8w>tr$lzJ)ONb>1~1!x16)%m;UX4W>b^PDccpzhC_OX0<7RxM zq69zouIBz%nN%7i);vRbS^3*|?BDy1>rt8e2E#epSxD$%Ecz1U|3W)jm1n}8X#x3( z{P1RnfL*xRA~W(b7RXrOma;%6+mj)-0sZbaV3cjepuNhqx9!^0adPnFVE+-VLNeiw zv;gZo?dUV%uC##TpW^&`S9bsI?Q|AL=jCie8*E3lHFj}`-W9ObXWy&}ojupI=Ip7Q zGFUr#DyJN3xnDCmVAZ3sKVz{vt}1J-z}*oh+y80<6FC3l`H!ns7Jl#{``?Y1&$N=4 zGb8Ug@^ntE&5|et)zw}%Sw6QjA#Q>H^ZTFdKl2;HT!zMXybnY~4o8wYnjPhfN}s(n zSMhoCiWqH;H=ADXgn8c5(>Hxd4swsOT3KjVgmis7QJ)m2(~k7@Pa0}C+U=$13)P_@ zE?=@0(vdG+ztQ0dA%{cQW7uDZuCtRbbzAR0`>@1~AcETI2uXx2Y>%9$nk`s`7^0Qt`%Km8u2wt+kF@xRoi Iul@e}ACjiphX4Qo literal 0 HcmV?d00001 diff --git a/apps/web/tests/fixtures/office/preview.ppt b/apps/web/tests/fixtures/office/preview.ppt new file mode 100644 index 0000000000000000000000000000000000000000..baa9e34a921a704c5cf612378330e1ea550bbcd0 GIT binary patch literal 606720 zcmeI41%Mny;&*#@V?e@&;J&y+Ah;&DC%7M$1UMuR+{5AS?i}v!9Kqe;INaTW%N^PO ztEt_|WU@&XsE<(Jyyo6)OLcYitDaxm`I1CPlBY@%#e-Q(}wzMPkVW1=Z6Z*U} zbb;|8^!o(R6}rKM&I4tv0!uovtN`@p`iAM6hY zz=3citW8aWShI;e--*A2OKY&8ox@~B$C19z?OCldFWOUnY-L&w;nJV6l?}_?{)oH| zE?Vh;o67HhnD6law}Cpo4Gjc**vsz69@PKrzRyf_*r&yn#T|2e9I}0lb(;DO+H=6* zeuK8#X}6}uLadH7)Wyqyk>>rji60t6?uT>7=0i_>?_*8`U5oD;8kk;g_rZe)u+{Z; z9WXeYi`p87H-~GG`cW2zIrT3+_0l=>PTl?F)4LRN&HeMWA>TVz%?p_v&Qt71iEvH} z>wfZUQ4Hm8_)df&oQp!4knv%8STD5k=9cL&4~@m<{Z?6i^Oe?HW%<(5aK&Ta4*>m_I+u7htRpV=HiGnuoJq zPs;VA#3C%~!x?X3&UA~Fl0BZ!TH8$ZV;vfcj~h2q*8ik#H6#^}d|A z!#;;LhkXr0m=1p$i*Fi^VZnUGqm5xNdlt_(wr;nvc&T&GQo-9AgY$Jci|xpnZP%iS zeICHMsi}0{*^#qfIG2rm=+wP?5$o#18EQM$3+KY$H+1UW)Kb^#)U*?4%)Mw&7~hJv z4dxse<_=(9xCR*W(24zy?K*msb;2dV9~e5Z?!}Ga_#0BX0tny89;NSPQC!g&j;+p6 zUyR3Bp?C4m#tG{}S~vf?4A)~bHpQ!lVtBa12y0Tb=p8z))N{?vUojTGC$Uy0DA~M! z6YEagRK4ztbxqVgrgcW$-m~>#Yhzkx)a?vw*n-1qOzYf5L;J&s{p#Lv2!~nQ>ogV< zbU22>jYTo+kg!(KX;@D_8;}~#YaG(0?=w5s;xb<2hGRQG#U-XM>V`4OHQ5BiX6Elf zb;a$?#lc~<;@-OMT=x#GW1>rQT^FvA>+0F@F1?HCzGgFF{VriynD-->$kP|Y8m9>> z7T-36j4O3XX}a_=Bz!g&&vss#m8LJ6Tg!9^->KFiY`C%br6F7sqN0?Ir7z;I4GS_O zTypjw!{4hXFN#;WZvXCo&COTS|Dn2_i~lyXM(WOtv`&X*y}8zJEIM~slJTO5+qjak zM`3h-+uFwTRZR}gTB-|IaZkCl3zKjnCvuc}NqdP2F<5O#y1uBhwKuVs`bh70OHJjAL$4F@Cb z{Hb9QPQmrXR}JMeRMp`V%56~$>)ga&+7Kp-Aw!0g{uWQ3S@i1lWbx#a&Bcv3-dHY2 z$#U7SO7oc%XC$nB+G(ei%QZJYS*mB?z+TkJ9oCur4Rffo`N@G5<>GdG^$Pc3Me)WP zZ&)ijOxhg&3;SD) zHY4lWk&$z=ep?yYx^7z;Ir2I)HnnC~>pIW2k&z>>Ga@5f*BRDEMvlDBh>UDqr>%@^ zU8nnKt%NhjaU4YdVaI!yjX7eA-zIk9*lxy(9WE-hpyqSL&a(>K)OS@lLrI9`~}b7}1v@Z9<-n z=*!l!Luz6#8;hQ7uZ!LV=zb09T!5Z z$%Cixm1{YkE@CY9MfLZuVKm3n#q*E-`$wgJ@OZkl>r!J6LB0re#n%nN2NezB4_>-<_^#BO^}{~rZ&S-PK={sd zreL@RIHa|=nK#yJ0M==}2Dp>*t$WLZzc#;9@3-UV2x9)&IjyKaqL`@1>!Y00sJNxI z-G4Dp;6zCGdiuH1wA(d@y+3;r3$_kmqsue+!FdQ-HOqI6H`_-<__Brb~8fWjf!&&&|{ju-*a2|#A=(6TiZ5?N==g(Bf*&jQ5 zRvu@aM&s<_MaF)drEnIGv$mauhNV2t+H>|C{c*N3t!d4(aDEDB(1SUH_GEPAbLX4) zY|FXxxbu&$ICpYR{$1zJFB@Lv(QfN|1vXW58*w8%B%Dmcx3MUG4lmB)iJw39rQdo5 zdn3PR2?fG=_kXMx?!CX`Z_k$VZs^N;_9>iq&nPW#=e;-Uw4QgjV7*pHb>XByyf$#} zy#=E~iVlm0BkF|B>-fdtqt^v{Vf>KN_@R`DzdEe`7WUYuzI@mH#i74e-F1gwy)8r2 zL+j&Rckg=sn`CIGaO6D8-0D=Hl{tO6#~R2T)Yja`HSu<8aOs|IJ3hnv?s?Wce8AxY zw%#_}0fZN8;YkhGAEi5h{w;R^yzpX}j}B-k{U#yY0|X1z^TIt+c&Z-Chn|Xe1EFM` zDy4jJX=7+rIPY{B=}I^Jq%XWb8^SBWp`i*6&v11RTGi?~^jSC-Ti<}TUb}@WxIcdF zcErRRmyU&_CSIGrMR8~8t@q~qCT??nQ`e6>;Q2X%isI;r7bq?NY~JwI_<6Gj>v~ST z(mRaK70;&Ojp_1VcPf>c?dv8MR1V?oMe7jmu)?>nu~_PBcDv|Zx9r!!S6ztEP|1D# zYIWr|t8p1U+Pd+8lIX?g!N z0}G1pRx9xt_iTr+Uwwqq* z$-KLN>C$qR30V@Jq9$O0S(lq0rS5$``x|PfCe;R{(B5)Km!`6O#|kCZT7o+tpN>a!2I_Z(0~Rs zP@4wK|Jv+#_gVuQ(17{xF`xkrXrML?nE$oe@9wn*G@t?V-(x@n8qh#(8ZiHBv)|oo z4QN0E=D){)1~j07+B9JP*Ji)F*Ba1(2F!nt0S#zC1GQ|0S%b{9s?TC zfCg&QfcamW{qA0CKm!^u|2+mYpaBikrUCQ6Hv8SZ)_?{yVE%gyXg~uRs7(Xre{J@= zd#wQtXu$mU7|?(QG*Fuc%>UZ#clTNY8qk3G?=heO4QQY?4VeG6+3)VP1~i}n^WS4Y z0~*jkZ5lBDYqQ_oYYk{X1LnWSfCe<6f!Z`+{?}%|yVn}fz-SHp%962h6xn$+90SL~ zad14G04Kspa59_%r^0D)I-CJ#!dY-OoCD{=d2l{l02jhV@K3lHE`dwoGPoSBfGgoD zxEij3YvDS$9&Uge;U>5lZh>3jHn<(`fIHzXxEt<)f5E*l6z+rj;Q@FM9)gGA5qK0H zgU8_scoLq1r{NiR7M_FW;RSdRUV?wa%kUp~1zv^M;B|Nd-h{W{ZFmRXh4)iz_;)nd=EdskMI-x48OpC;eYTe42NbY>i8ejLj!bx zj?f7@Ll+ni#)k=@D|CYip%J>nM9>2!hDl&jm<%R|DPT&N3Z{l>U|N_CriUh&0cM1m zU}l&FW`)^cc9;X^gt=gDm<+rtj9BkTl&U}x9`2E(qf8|)5yz@D%d><#Qf55?T2pkHB!QpTO90^Ck(QphL3&+9nZ~~kNC&9^Z3Y-e3!Rc@YoC#;a z*>Db=3+KW4Z~k@h0EY_xB{+(tKe$52Cjwc;Ci?LZiJiQX1E1zh1=kE zxC8ElyWnoP2mS^3!ce#m?uQ59L3jurhDYF0cnltgC*VnV3Z8~%;8}PMo`)CUMR*DR z4KKrg;1zfkUW3=+4R{mYg16xvco*J-_u&Kh5I%yB;S=~2K7-HU3-}Vgf?@DAd;{OY zckn&@06)S{@H6}Z|Aqg-uP_{%p{VEk5B1Oh9iSt0g3izd#)I);0_Y0eU_xkw?l2Mb zfQexem=q?1$zckZ5~hNwVH%hgri1CB31)y9VJ4UvW`S8@HkcjefH`3cGSd0{^2 z3G>4O&s00Utg*cP^fzr*&h z1MCPp!64Wfc7egLE9?fl!yd3F>;-$nKCmzB2m8YTa3BnUgWw->uUWM1-b$A2bgty>rcn98v z_uzf_06v6|;A8j%K84TVbNB+jgs)&2d=1~gx9}Z&4?n<<@DuzDzrcUtfAA{|hh``m z`2IsZG(ZRF2%Vrabb;|;e3$^bLN}NY8lgK(1U+D4m;@$;$zXDr0;YtiU}~5KriJNX zdT4?fU`Ch;W`AZ3+{${;9qbr42ApPes};Lgoof^cmy7W$KY{z0-l7Y;AwaUo`vV& zd3XU{gqPso@G|@dUV&HPHFzD~fH&bScpKh6bGcrZRp09~OQ zObCt89VUVvFfmL5lfq;$IZOdl!c;IdOas%xbTB-U~||4wuG%OHU?6M* z+roD6ci0|wfE{5c7z8`RE-)B&h23Cx*aP;2yiV1GCO4um0a5c~rUhC|>` zI1CPlBj89l3XX*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B z*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c z#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0E zi4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$ zKm!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC z4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(; zSg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvq zNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B z*ns(;Sg6%$Km!`c#DMvqNk`dC4QN0Ei4B*ns(;Sg6%$Km!`c z#DMvqNk`dC4QN0Ei4BcxhnW|CNSV3G;qmZLXyO4QSxcH(>t%`Oe*<1~j07zk&ht|M&Gm`TGBrS6+GH zg%_x#Jm>dS=vo@kfCm0#13Bh@e9XeiG zXPtG{xC({KE4JB~@=`{|T6oMg#MR3cwprMj8qk0S#$X_?{4dAde@jCQ=Qv(o#LhC# z%$UziQjE!RfnR?4g*nj)BXKg8DbJ@ctFsn!7|N?KpCN7{*1y6EE6g_AY|M#;nHlHD zGL^1pojA4~FCSu*x)xsr03@<=Qk>nzVKkCa!Bb4G0#^UIqnuQi5| zSfQU9(0~SV$^RH=G3b8!>8JD0Kc5FLv&=FJ*}Kw8D_wTkWn6GpCgG-=ZhGvo$HxAp zaNoXtfBf-BCf|AIo#mEW?%sRvWt{R~e);9DyY4#s?6Xr<|Ni|y{P08O$6y~ibSO1b z|D1Eqxy?4)eDJ{sEUL`vSd7|w_U!r8Q&08j(`TlcW?E*MWmu7t3~#>q=CaE!`_MxV zF;3yQ?i+8svBef!aLd53#u{rp_Sj=AWl>!E>8GF4R2u*N_ut30_&ogZ!#Cf2^H_|} z2OfA}l~q=W4WrHPz4sn7V`JDmwz$te`!EL~e4cQ^35zYZ*fY;O6U#)6jF*SF;%&Fx z#))jb_10T`_0`ub4UDY2?z(HOwbnZ8tiuTNXf2=2U%NCglfNvDtFV-za+NqIuEP8k zS6q>fV7qaP;j-)2#420{PD;C zRT>_7S2OdWn)T|Iaw%494TV03``$CQm)}RK{7I(+VT8{*OQYc*`xfeD1mD z;+)W$*8JxRnYFkkXAuMEAkurQt+ryE0A$fM*IdJRjQ348*@Tu*8*`|EPa=_X4I?p! ziA~C|BV=!REiTKanrbR088~+k>5RnH*&N%7ryFK+U%*v2XDhDfnPg|DpMH8OoNvDQ z;wqutBmX0XxLr;qOD(k&>vESA3!t=j@7@b9yfF7E+-k*3_gEOQoRTJ-a6)cXXlMET z!ku^C$xRT=n`^GQICXNGV54yr+OYJ}OYgn+-sL%rIA}lv#D1+Vx01n>CvOd`s=SBtBo;R zUc|`jufI>y*zM5l>Ik?p4tidVc`RAYK^R2huV$ned z9mLu5$}6wrF&dNQmNV|40S$~p1AL3e;nv%~>6rgqd6t8bD2uDd5gy&MXyCws%p_sP zoc|YHbP>h4QYE7pDJ5vg=eQNFW2tI`4K_IV;Dh(wci;U>!vO~zz}bhwF+y1-7Os5% z8|SRH+G^xHC3%EGJp?l244;4g`FP`v$HS{b4?Q$i%VQZHJ(1@>{O|(>*j#*0759Z_ zGNtuKesh$%LkCqKma{PWMxy6i$c-LPGrI57t^ZRS4@ui`VJc)s9_ z8BKDx6;C~!Ub)nN_uY57xnL9b+;b1RcIKI9QYQ|%)7gIe?FS7S#3Y-ZcG_tf;gK3| z2Y5EcJqxE-o}`)oXmQZMU&jF7;=fK~M%NM|D~3GbN&duQF|^|+b11+=67q`inEyO; zh$Z9oKbOZ$#_Ru>|BElaIM2`Hx-tA?fb-NEEnLm=PJs#;7-0^tGAT@%xY1aci*2ru zVqwCVzrDkHa1Q@_0;jkfp^e6z=?%<`V^n@Fj>C- zfB4~tX$E;))v#4pU6o=x?X=UBQ%=bUPqetN;ToQj@m`7d1+r^IH& z=RcLd#$gf9i(<)mMkARysWFEi0=0_iWL>ZpIFdiGm zjRWt0c?r%OP9Ga>v=Jj*pYsZs+Xp^l4XiT31QW!&3|dZ^@=(ryZYY>ZZnLcc0|vwb zyc8hY;L#T<*1{Miks#M2{vxJ z=_V$*TApa4iQa$z{kVvSKP;;J6=k_eJdI(|o_p>YOY*xNCVAq`WQ=q&pNs1Fd(LBy zIfkWo+;K-N%m|b5M6=_LJ2o^laE}ldee%gCdrgw%nRS!Z@-;MPG|9kh*J-%bNY*&;`Kjr z95nEkGQhX^FV&vWH73RskEv-ek3lHLS%4^Gh^K?Q@4lNwXPtExuTA+KK5snvfeLRe z`JFtc3?_Ld!!;^zBq>(T$s>+9f=TjZ*@qpxQHJ*GM^+Q`57ta zKNs*ksNr6NoaFX|O8Cu8{1GYdPnqOtJX_~E50TD_lw>9k$@x(wBk?6QSLU>qAJtGG z?i3?D)!}&yBZN7XGs3{NGy%(*f+t9vS*Ri24#fKf-XO&GMbFV z_>7aBM^M4#pO1oS!w4z~`KE4zb2kf~fM}OmSt)mH+X_AJ5}@ zs)`9uwLDTJpe8x+CN^%z9Dw?)&!XiB5 zBN665@x&9{2r%%YdL|jZ`Q{s5F2|qomnsfBMVRy3QTCLp_jqX?7tt_o9M~4m_RE3K zdkxBP?kLq4Hcf?`?3j$|XeR@cvDvX0pD~TO3F0jYzuqLq<04+>a=zf{7Vkm0or&ir z7STwa@Uv--9M6sd{Op7*<-x*q(@n=rvX_!E?s#CruV3Pd{QjT(Cx@Ag=~w=w z&hj`*%ac?R>nSfSFN$+w&E=()g&8l;r&xJ1ZawY=1!9?UwQ*^jSvg)gC)QTEEtZyR zV4R_9-BIUIpmK<5%zYCAIVG_u&S6D<$ixpyIg@f>AOCG#T*O(48xsazJ#gMEufkHo zKd!<(5s#$Gi}-~S^;fKV5yPkri#yJuD3+1R3ggbQ zR(bDYF+M5AC$}faL>dDR|A}#Kn&J~GH1aBef9l1PAN~(9SjtTSAA3F+n-MGIxfyS_d11lxe4ghp@UocmEN_+>cuvJ6Gg%r3o|Q45H@tCXSfR8E?*&S0 zj97*FtimTJ==guHlzbNw+OR4O)Rci-@}EwLxxpu?7=w{<1{PJ$i6!Iw@@E{Wsh)Lb zHJ||vjM+d&`5$+r9O?0i2g%P!c_wAzI5Xn|mbkQXoK-5zkJ-<-U0U9ks(nn@ss&_InCF#l_$quosnXg~v1 z2F(8|Ax>&Q0~)9i1Ll8?bhNvv0S#!N%7FP_CB#V$Xg~usV!-^bk&bpZHJ||vR2eY; ztAseI0S#!NMhuw$HPX@UrUo>ifhq&$f0Ym?HJ||v)QAD|zeYOR-PC{vG*D&0{I3$? zqy{vgff_Mj{?|xHyPF!&fCj1znEzEmoYa5@G*BZ3%>NqcXm?Ws8qh$M0rS5~h?5%7 zfCg&Bfcak|9qn#vKm!`6GGP8!32{;b8qh$E7%=~9q@&$U4QN0ERR+xeDj`m4Km!`6 z5d-FbjdZlTsR0dWpvr*xUnRsz4QN0EHDbX0uaS;+H#MLE4OAI0|Eq*JsR0dWphgUs z|25Ll?xqGbpn)m_=6{tCCpDk}4b+GM^S?$q+TGNE1~gD*!2GWg;-m&Npn)1OVE)%g zN4uLE(0~T244D5_LY&lq1~gD32F(8&>1cOT0~*jkl>zg=N{Evh(0~SN#DMu^*XrM+6nEy4>(e9=OG@yYh z1Ll8~5GOUD0S(lM0rS5`I@;aTfCe;BWx)Ke65^x=G@yYRF<}1JNJqPy8qk0SstlO_ zRYIK9fCe;BBL>X>8tG_vQv({%K$QXWze0Y?QXf|&Zl@o;zh`NW!`OB$7M5pxYudD9i|Lxv3kI_F!EAd=yllt#PO)g4pJDsIb^j-)rq6%rQmU_GQN6Fftv!qWj_rr< K$~561WcWY31dM_J literal 0 HcmV?d00001 diff --git a/apps/web/tests/fixtures/office/preview.xls b/apps/web/tests/fixtures/office/preview.xls new file mode 100644 index 0000000000000000000000000000000000000000..31e17e67110a89175642577df4db4d3ca23b6151 GIT binary patch literal 5632 zcmeHLU2GIp6h1Ti!)z(-ZVQMYb!?(7v>4GuF^FCH5lz50h)N7mw#yV&mX@rQpTx#R zB)mw}@ZbvxA?b_JsKi7cG;#apgGPhlK@*eWi!uJN7>yx;uHScNrX4rCuuB>YX7A?Q zd*;l!_x#**&$+YTe4Us)eP;1_DQl}GBvYlBL<4jKxaB{`MNHtNO_fR|pA59oZXyp{ z@pR=LhOvne0QYkQL$x%3}ej6A?$eVZ6P8iD6W>7!F*8* z|Fw7;@@G77Eno3{(ErTe%;OEe4xcCvY z?Xr)tu`ZQO+c&DMRUJ|WW3zxDT?5RJlc2W92TJ`+`!yPUU`mR;3IlH>+svfueq8%b z?OV_r@`=v*UMVZ(Uikw2rB~kc4ne}$BpGDEm*gN~llY)G$cUp#bwJxFD-q3sS(qO& z9sy5AQj(p`w=g`oa!0sX##xm&I>Sybm7UbnHZFgaZ0O*(f=xQjQTm^?(dpw#{MG1O zwG#jTmp%@k+BDB=zMp$sKNQR9%c{_OtI(HMp~ouHBZvgmXZkW;uuqscp?N8BjORl- zCh}1o+wx%@+w*gDoS%=BcIZhsiah=s(>@|=Am{AQI0s(GQGXi03Bv-2Y&~wsFY+uC zesPQ187(rgGb1oS7;HhfPv#a`5I)DJf+dFy6cDRmB+)0O<04B2N{EG0VxdG>f1`?A zmetcMv`fsCei_0qBWavI8@VTkaX+3NIe_OCPaJcOIqBWJtFj7}P@gP$1M09orL6@3 zAYNlVVl!F9++-L3SF&q{6+5vI(?dcnietIU?_4~)d2pcbd8Ow;@!OqrG)z8>HxuFI zI-^JXSC!WCJNys>0V%@+CLemX<+8T(;8*W^Bn$#U)Pk=t7)cY*j79mQw`M*q0w zTjW<6pht?2fdX$7X+8Itd)_Lc;yplHC0y)QdaUTKM~0bZN|Vmx9DYV$WpH7PEr9tcOfu#&G;Ke8Z3rna&>gsz23mhA_O{F;uZrE^+J1jbP2}InRIUC z#gU;=d&9xBleHgwV8zzdpp(ryTI$wjhKAg-;m)bMht{spx@Wo*reGMixV+IDlSfbe zajAcI{N1-K>FN6DJG%X40KLffRGjy-B!I5j1mHbw8-Qnl0mS>-J^&^BjQ#HLaITdE z_$}!p7^~p{mT9z_{*-5)`|8}uyO+dIybb+(FTKAWv`86kJ7`QNw1>b@@^!672TP;P zk_Tq<`%m|;> = {} + let main: string + let parts: Record + switch (extension) { + case 'doc': case 'xls': case 'ppt': + return readFileSync(new URL(`./fixtures/office/preview.${extension}`, import.meta.url)) + case 'docx': + main = 'word/document.xml' + parts = { [main]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml' } + files[main] = `${TEXT}` + break + case 'xlsx': + main = 'xl/workbook.xml' + parts = { + [main]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml', + 'xl/worksheets/sheet1.xml': 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml', + } + files[main] = `` + files['xl/_rels/workbook.xml.rels'] = `` + files['xl/worksheets/sheet1.xml'] = `${TEXT}` + break + case 'pptx': + main = 'ppt/presentation.xml' + parts = { + [main]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml', + 'ppt/slides/slide1.xml': 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml', + } + files[main] = `` + files['ppt/_rels/presentation.xml.rels'] = `` + files['ppt/slides/slide1.xml'] = `${TEXT}` + break + } + files['_rels/.rels'] = `` + files['[Content_Types].xml'] = `${Object.entries(parts).map(([part, type]) => ``).join('')}` + return zipSync(Object.fromEntries(Object.entries(files).map(([path, content]) => [path, strToU8(content)]))) +} diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0819e0e8ce..f27edc18d8 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -18,6 +18,7 @@ */ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { once } from 'node:events' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' @@ -211,7 +212,8 @@ async function respond( */ async function serveDist(overrides: ReadonlyMap): Promise { const server = createServer((request, response) => { void respond(request, response, overrides) }) - await new Promise((listening) => { server.listen(0, '127.0.0.1', listening) }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') const address = server.address() if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port') return { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9d58ad53b2..0d2901744a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -436,24 +436,29 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt']) - // Path label survives from the recorded args (a.txt). - await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) - const path = column.locator('[data-textpreview-path]') - const absolutePath = join(scaffold.workspaceCwd, 'a.txt') - await expect.poll(() => path.textContent()).toBe(absolutePath) - expect(await path.getAttribute('title')).toBe(absolutePath) - await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') - const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) - // Put the column back so the later goldens see the default frame. - await column.locator('[data-sidebar-right-toggle]').click() - await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' }) - await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' }) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt']) + // Path label survives from the recorded args (a.txt). + await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) + const path = column.locator('[data-textpreview-path]') + const absolutePath = join(scaffold.workspaceCwd, 'a.txt') + await expect.poll(() => path.textContent()).toBe(absolutePath) + expect(await path.getAttribute('title')).toBe(absolutePath) + await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') + const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) + } finally { + // Later cases share this page and require the sidebar closed even after a failed assertion. + if (await frame.getAttribute('data-rightbar-collapsed') !== 'true') { + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') + } + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' }) + await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' }) + } }) it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3844a5d8be..2bc7c7cd97 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -45,6 +45,7 @@ "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/document-preview.e2e.ts", + "tests/office-fixture.ts", "tests/plugin-config.e2e.ts", "tests/plugin-manager.e2e.ts", "tests/plugin-install-cancel.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index f2afd33425..e9297266ff 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 88308afe9d8acff4d021f57ce5884cace44c894d -capability-seams.zh.md: a81cbb977d85234f203db64309ef5cfa0268f3f3 +capability-seams.md: 536fb616f4eb7474c4f0fafb856f4c916440f230 +capability-seams.zh.md: e39281f59783b9d9fe305d966a4392e7847788fc diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 88308afe9d..536fb616f4 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,6 +26,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_api_document_render_controller["api-document-render-controller"] + svc_documentRenderController["ctx.documentRenderController
Host Office preview Remote"] pkg_office_to_pdf["office-to-pdf"] svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] @@ -253,6 +255,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets + pkg_api_document_render_controller --> svc_documentRenderController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -431,6 +434,7 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_mcpResources --> pkg_mcp_resources + svc_officeToPdf --> pkg_api_document_render_controller svc_pluginManager --> pkg_plugin_manager svc_pluginManager --> pkg_ui_settings_plugin_inventory svc_profileContext --> pkg_plugin_manager @@ -533,7 +537,8 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `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.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | +| `ctx.documentRenderController` | `core` | [`api-document-render-controller`](../packages/api/document-render-controller) | - | - | - | Authorizes Session file reads and returns rendered PDF bytes with the source identity. | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`api-document-render-controller`](../packages/api/document-render-controller) | - | 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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a81cbb977d..e39281f597 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,6 +28,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_api_document_render_controller["api-document-render-controller"] + svc_documentRenderController["ctx.documentRenderController
Host Office preview Remote"] pkg_office_to_pdf["office-to-pdf"] svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] @@ -255,6 +257,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets + pkg_api_document_render_controller --> svc_documentRenderController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -433,6 +436,7 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_mcpResources --> pkg_mcp_resources + svc_officeToPdf --> pkg_api_document_render_controller svc_pluginManager --> pkg_plugin_manager svc_pluginManager --> pkg_ui_settings_plugin_inventory svc_profileContext --> pkg_plugin_manager @@ -535,7 +539,8 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `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.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | +| `ctx.documentRenderController` | `core` | [`api-document-render-controller`](../packages/api/document-render-controller) | - | - | - | 授权 Session 文件读取,并返回带源文件身份的渲染 PDF 字节。 | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`api-document-render-controller`](../packages/api/document-render-controller) | - | 已授权的 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(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 08c47ee2ef..f61eb090bd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 7f9a195843af4bef80634ddf1501e30d71eff859 -config-catalog.zh.md: 6ce8b9ebb185ebb2f7991de9ed4db9de060de66d +config-catalog.md: 0899c12e1763e74bdac2bac8b51e8f714c0b8dc9 +config-catalog.zh.md: b36ed58ddd1d88415849112290b80368fac6164a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f9a195843..0899c12e17 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -446,6 +446,29 @@ export interface Config { Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` + +```ts config-catalog +/** Transient Office conversion reuse within one Client connection. */ +export interface Config { + /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */ + office: { + /** Maximum retained completed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */ + maxCachedBytes: number + /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */ + maxPending: number + /** Maximum readers including source and renderer metadata lookups. */ + maxReaders: number + } +} +``` + +Source: [`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts) + ## `@deepseek-ai/dsh-compaction-basic` @@ -3705,6 +3728,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-api-document-render-controller` — requires `workspaceFiles` · `officeToPdf` ([`packages/api/document-render-controller/src/index.ts`](../packages/api/document-render-controller/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) @@ -3745,7 +3769,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6ce8b9ebb1..b36ed58ddd 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -448,6 +448,29 @@ export interface Config { 来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` + +```ts config-catalog +/** Transient Office conversion reuse within one Client connection. */ +export interface Config { + /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */ + office: { + /** Maximum retained completed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */ + maxCachedBytes: number + /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */ + maxPending: number + /** Maximum readers including source and renderer metadata lookups. */ + maxReaders: number + } +} +``` + +来源:[`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts) + ## `@deepseek-ai/dsh-compaction-basic` @@ -3707,6 +3730,7 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-api-document-render-controller` — 需要 `workspaceFiles` · `officeToPdf`([`packages/api/document-render-controller/src/index.ts`](../packages/api/document-render-controller/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) @@ -3747,7 +3771,6 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 18708d461b..d63f1a530d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 7997fd2ed50db0e375ea0c40eb76cf402494bb06 -event-producer-consumer.zh.md: 2c3ddb928cae1a1e53822aaacdd09ed3ab2c2d46 +event-producer-consumer.md: 9b47e2b1fff172da5eab747b79d6fc2ab41e023a +event-producer-consumer.zh.md: 02b38b500ee93c329d83b482f213c493708cd611 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7997fd2ed5..9b47e2b1ff 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -74,7 +74,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2c3ddb928c..02b38b500e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -76,7 +76,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c06a093929..e9e63f0641 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2949593ccf9f67732d0a91b375e6a1b0e613e502 -module-graph.zh.md: d80d5a5947926753d0ef344884a3f4df4fad53e7 +module-graph.md: 468bbfbbc372c77578f160f9a891dffd3a6ebd75 +module-graph.zh.md: 383ea5a2d423a8fe24a782973317771d07fa03e1 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2949593ccf..468bbfbbc3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -113,6 +113,7 @@ flowchart TD pkg_acp["acp"] end subgraph group_api["packages/api"] + pkg_api_document_render_controller["api-document-render-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -404,6 +405,7 @@ flowchart TD end pkg_scope --> pkg_invariants pkg_web --> pkg_llm + pkg_api_document_render_controller --> pkg_office_to_pdf pkg_attachment --> pkg_brand pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand @@ -1393,6 +1395,7 @@ flowchart TD | [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | +| [`api-document-render-controller`](../packages/api/document-render-controller) | `api` | [`office-to-pdf`](../packages/document/office-to-pdf) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d80d5a5947..383ea5a2d4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -115,6 +115,7 @@ flowchart TD pkg_acp["acp"] end subgraph group_api["packages/api"] + pkg_api_document_render_controller["api-document-render-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -406,6 +407,7 @@ flowchart TD end pkg_scope --> pkg_invariants pkg_web --> pkg_llm + pkg_api_document_render_controller --> pkg_office_to_pdf pkg_attachment --> pkg_brand pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand @@ -1395,6 +1397,7 @@ flowchart TD | [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | +| [`api-document-render-controller`](../packages/api/document-render-controller) | `api` | [`office-to-pdf`](../packages/document/office-to-pdf) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml index edca358c9c..4d2631328f 100644 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ b/docs/subsystems/office-to-pdf.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md -office-to-pdf.md: 71254a3a8a8b4665d8d42b935cc836cb7c644b10 -office-to-pdf.zh.md: c11889fc9bd4693d90ec60696fce4b57693fd027 +office-to-pdf.md: 7419df5d07c3fcd238159fa3026299d8866e4e50 +office-to-pdf.zh.md: c1238a9f78003edbd53838f369e42c018d8bf3a0 diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md index 71254a3a8a..7419df5d07 100644 --- a/docs/subsystems/office-to-pdf.md +++ b/docs/subsystems/office-to-pdf.md @@ -10,6 +10,8 @@ The [document package family](../../packages/document/README.md) converts Office |---|---| | [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching | | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | +| [document-render-controller](../../packages/api/document-render-controller/README.md) | Session-authorized source reads and the `documentRender` Remote namespace | +| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices | ## Requests and results @@ -26,6 +28,14 @@ The [document package family](../../packages/document/README.md) converts Office The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. +## Preview reads + +`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF. + +The controller authorizes metadata and defers the bounded source read through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service, passes the read callback to the renderer, and returns the PDF as base64 with the source absolute path and freshness version. Source access failures pass through; engine failures expose a classified reason without diagnostics. Both the complete-file read limit and renderer input limit apply. Conversion does not activate an Agent or append events. + +The controller's Client entry mounts the 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-harness/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 decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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. @@ -40,6 +50,33 @@ 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). + + +### `ctx.documentRenderController` — `DocumentRenderController` + +Converts authorized bytes without activating an Agent or appending Session events. + +```ts cordis-catalog +/** + * Read and convert one Office file using the Session's ordinary filesystem authorization. + * @param workspaceFileScope - Session header lookup shared with workspaceFiles. + * @param path - absolute or workspace-relative Office path. + * @param priority - foreground preview or speculative background work. + * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. + * @returns complete base64 PDF with original source identity and missing font families. + */ +@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise + +/** + * 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(signal: AbortSignal): OfficeToPdfGeneration +``` + +Source: [`packages/api/document-render-controller/src/index.ts`](../../packages/api/document-render-controller/src/index.ts) + ### `ctx.officeToPdf` — `OfficeToPdf` diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md index c11889fc9b..c1238a9f78 100644 --- a/docs/subsystems/office-to-pdf.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -10,6 +10,8 @@ |---|---| | [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 | | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | +| [document-render-controller](../../packages/api/document-render-controller/README.zh.md) | 经 Session 授权的源文件读取与 `documentRender` Remote 命名空间 | +| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 | ## 请求和结果 @@ -21,11 +23,19 @@ |---|---| | `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | | `missingFonts` | 本次转换无法使用的文档请求字体名称 | -| `cacheKey` | 不透明的转换 generation 加扩展名与源内容身份 | +| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 | | `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | 提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 +## 预览读取 + +`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 + +控制器通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务授权元数据,并向渲染器传递延迟的有界源读取回调,并以 base64 返回 PDF,同时保留源文件绝对路径和新鲜度版本。源访问失败直接传递;引擎失败只暴露分类原因,不含诊断信息。完整文件读取上限和渲染器输入上限同时适用。转换不激活 Agent 或追加事件。 + +控制器的 Client 入口挂载生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。 + ## 引擎选择和限制 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 @@ -40,6 +50,33 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.documentRenderController` — `DocumentRenderController` + +Converts authorized bytes without activating an Agent or appending Session events. + +```ts cordis-catalog +/** + * Read and convert one Office file using the Session's ordinary filesystem authorization. + * @param workspaceFileScope - Session header lookup shared with workspaceFiles. + * @param path - absolute or workspace-relative Office path. + * @param priority - foreground preview or speculative background work. + * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. + * @returns complete base64 PDF with original source identity and missing font families. + */ +@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise + +/** + * 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(signal: AbortSignal): OfficeToPdfGeneration +``` + +Source: [`packages/api/document-render-controller/src/index.ts`](../../packages/api/document-render-controller/src/index.ts) + ### `ctx.officeToPdf` — `OfficeToPdf` diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 4c47c51e7d..ed445b7a15 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 3b6e5eb08fc2c0a0bac74369bde4a48b2ee0fdb6 -sidebar-right.zh.md: 680e2ae16f8f8aeeea57b11172be8c8c9e359122 +sidebar-right.md: a9864e0caa11042dabb9a71873d846f0fac3c442 +sidebar-right.zh.md: 3af1f3c2ec1746706769db08e70c206305475e49 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 3b6e5eb08f..a9864e0caa 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -106,14 +106,16 @@ A body, title and guide replacement receive the framework-injected `useTabInfo() ## Document renderers -The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. +The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer may instead select an existing component with `bodyId`; its optional `read` callback prepares complete bytes while preserving the source file identity and version. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. -The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control. +The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. The optional `supportsText(path)` callback controls whether a matching renderer permits the plain-text alternative; defaults follow `loading`, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control. [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. Preview records its loaded version and the version observed when a read starts. 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)). +Office registrations request [Host-rendered PDFs](office-to-pdf.md), then reuse the same PDF body and controls. Their cache rechecks Session source access and version, and the document notice slot displays missing-font information. + ## Resource model The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 680e2ae16f..3af1f3c2ec 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -106,14 +106,16 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed ## 文档渲染器 -`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 +`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器也可通过 `bodyId` 选择已有组件;可选的 `read` 回调准备完整字节,同时保留源文件身份和版本。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 -[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。 +[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。可选的 `supportsText(path)` 回调控制匹配的渲染器是否允许纯文本备选;默认值由 `loading` 决定,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。 [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 +Office 注册请求 [Host 渲染的 PDF](office-to-pdf.zh.md),然后复用同一个 PDF 正文和控件。其缓存重新检查 Session 源文件访问和版本,文档提示 slot 显示缺失字体信息。 + ## 资源模型 模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 diff --git a/packages/api/document-render-controller/README.i18n.yaml b/packages/api/document-render-controller/README.i18n.yaml new file mode 100644 index 0000000000..438653cb4a --- /dev/null +++ b/packages/api/document-render-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/document-render-controller/README.md +README.md: 54ead67d5cca63effbfa851a433547ba944c048d +README.zh.md: 6ed388af3cd4be5947deb4de34af50d1200f6339 diff --git a/packages/api/document-render-controller/README.md b/packages/api/document-render-controller/README.md new file mode 100644 index 0000000000..54ead67d5c --- /dev/null +++ b/packages/api/document-render-controller/README.md @@ -0,0 +1,76 @@ +--- +description: "Session-authorized Office conversion returned as PDF bytes for Client previews." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-document-render-controller + +English | [中文](README.zh.md) + +## Summary + +Open an Office file from a Session and receive its rendered PDF. Source file access follows the ordinary workspace-file policy, and conversion does not activate an Agent or append Session events. + +## 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) + +----- + + +## Use this package + +The [Web bundle](../../bundle/web-app/README.md) mounts this controller as `document-render-controller` beside `workspaceFiles` and `documentRender`. The controller has no configuration. Its `documentRender.render` Remote accepts a Session identity, a DOC, DOCX, XLS, XLSX, PPT, or PPTX path, and `foreground` or `background` priority; the shared Session lookup derives the workspace root on the Host. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The controller probes ordinary read access with a one-byte `workspaceFiles.readBytes` call and confirms the source version through `workspaceFiles.stat`, then supplies a deferred `workspaceFiles.readAllBounded` callback to the renderer. Admission precedes source allocation; the callback checks metadata after reading to reject concurrent source changes. A bounded-read size failure also rechecks source identity, reporting `source-changed` when the current source identity differs. Cached responses repeat the read probe and metadata check. The result carries base64 PDF bytes, original absolute path/version, and renderer generation. The `generation()` Remote lets Clients invalidate PDFs after renderer replacement. Other source-read failures pass through; conversion failures expose a classified reason without engine diagnostics. Cancellation releases this reader’s interest; the renderer retains active capacity until actual work settles. Controller unload joins its outstanding requests. No runtime invariant companion is published because results are transient and have no independently persisted state. + +
+ +----- + + +## Further Exploration + +- [Workspace files](../workspace-files/README.md) — read policy and Session lookup. +- [Rendering service](../../document/office-to-pdf/README.md) — conversion inputs and results. +- [Office viewer](../../client/ui-sidebar-documentpreview/README.md#office-preview) — binary Client caching. + +----- + + +## Model Experience + +None, as this package serves previews without model-facing tools, messages, or Session events. + +#### KV Cache effect + +None; preview operations do not construct or modify model requests. + +## Known Limitations and Deferred Work + + + +- Complete source reads also obey `workspaceFiles.maxFileBytes`; increasing the renderer input limit alone does not increase the read allowance. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/document-render-controller/README.zh.md b/packages/api/document-render-controller/README.zh.md new file mode 100644 index 0000000000..6ed388af3c --- /dev/null +++ b/packages/api/document-render-controller/README.zh.md @@ -0,0 +1,76 @@ +--- +description: "经 Session 授权的 Office 转换,为 Client 预览返回 PDF 字节。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-document-render-controller + +[English](README.md) | 中文 + +## 概述 + +从 Session 打开 Office 文件并取得渲染后的 PDF。源文件访问遵循常规工作区文件策略,转换不会激活 Agent 或追加 Session 事件。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +[Web bundle](../../bundle/web-app/README.zh.md)在 `workspaceFiles` 和 `documentRender` 旁以 `document-render-controller` 条目挂载此控制器。控制器没有配置。其 `documentRender.render` Remote 接受 Session 标识、DOC、DOCX、XLS、XLSX、PPT 或 PPTX 路径以及 `foreground` 或 `background` 优先级;共享 Session 查找在 Host 推导工作区根目录。 + +----- + + +## 理解实现 + +
+实现内部细节 — 点击展开 + +控制器先通过单字节 `workspaceFiles.readBytes` 调用检查普通读取权限,再通过 `workspaceFiles.stat` 确认源版本,然后向渲染器提供延迟的 `workspaceFiles.readAllBounded` 回调。准入先于源分配;回调在读取后检查元数据,拒绝并发源变更。有界读取因大小限制失败时也会复查源身份,若当前源身份不同则报告 `source-changed`。缓存响应同样重复读取权限和元数据检查。结果包含 base64 PDF 字节、原始绝对路径与版本以及渲染 generation。`generation()` Remote 供 Client 在替换渲染器后使 PDF 失效。其他源读取失败直接传递;转换失败仅暴露分类原因,不含引擎诊断。取消会释放当前读取方的使用权;实际工作结束前,渲染器保留活动容量。控制器卸载会等待其未完成请求。结果为临时数据,没有独立持久状态,因此不发布运行时不变量伴随入口。 + +
+ +----- + + +## 延伸阅读 + +- [工作区文件](../workspace-files/README.zh.md) — 读取策略和 Session 查找。 +- [渲染服务](../../document/office-to-pdf/README.zh.md) — 转换输入和结果。 +- [Office 查看器](../../client/ui-sidebar-documentpreview/README.zh.md#office-preview) — Client 二进制缓存。 + +----- + + +## 模型体验 + +无,因为此包仅服务预览,不提供模型工具、消息或 Session 事件。 + +#### KV Cache effect + +无;预览操作不构造或修改模型请求。 + +## 已知限制与延后工作 + + + +- 完整源读取也遵循 `workspaceFiles.maxFileBytes`;仅提高渲染器输入上限不会增加读取额度。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/api/document-render-controller/package.json b/packages/api/document-render-controller/package.json new file mode 100644 index 0000000000..d857d97356 --- /dev/null +++ b/packages/api/document-render-controller/package.json @@ -0,0 +1,84 @@ +{ + "name": "@deepseek-ai/dsh-api-document-render-controller", + "description": "Authorized Host Office previews returned as complete PDF bytes", + "version": "0.1.6-alpha.1", + "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/api/document-render-controller" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + } + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-typert-generator": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } +} diff --git a/packages/api/document-render-controller/src/client/index.ts b/packages/api/document-render-controller/src/client/index.ts new file mode 100644 index 0000000000..5af091db58 --- /dev/null +++ b/packages/api/document-render-controller/src/client/index.ts @@ -0,0 +1,16 @@ +/** Mount the Host documentRender namespace for the Client connection lifetime. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import documentRenderRemote from '@deepseek-ai/dsh-api-document-render-controller/remote' + +/** Remote carrier used by the Office preview plugin. */ +export const inject = ['remote'] + +/** + * Register the generated documentRender Remote. + * @param ctx - Client context with its Remote carrier. + * @returns disposer that removes the namespace and joins pending requests. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(documentRenderRemote) +} diff --git a/packages/api/document-render-controller/src/index.ts b/packages/api/document-render-controller/src/index.ts new file mode 100644 index 0000000000..fd996d87ed --- /dev/null +++ b/packages/api/document-render-controller/src/index.ts @@ -0,0 +1,107 @@ +/** Session-authorized Office preview Remote; source bytes never travel to the browser. */ +import { extname } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type { WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' +import { brandString } from '@deepseek-ai/dsh-brand' +import { OfficeToPdfError, type OfficeSourceKey, type OfficeToPdfPriority, type OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { RenderedDocumentBytes } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client-only Office preview over authorized workspace-file reads. */ + documentRenderController: DocumentRenderController + } +} + +/** Converts authorized bytes without activating an Agent or appending Session events. */ +export class DocumentRenderController extends TypertRemoteService { + static inject = ['workspaceFiles', 'officeToPdf'] + private readonly lifetime = new AbortController() + private readonly active = new Set>() + + constructor(ctx: Context) { + super(ctx, 'documentRenderController', { namespace: 'documentRender' }) + ctx.effect(() => async () => { this.lifetime.abort(); await Promise.allSettled(this.active) }) + } + + /** + * Read and convert one Office file using the Session's ordinary filesystem authorization. + * @param workspaceFileScope - Session header lookup shared with workspaceFiles. + * @param path - absolute or workspace-relative Office path. + * @param priority - foreground preview or speculative background work. + * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. + * @returns complete base64 PDF with original source identity and missing font families. + */ + @Remote + async render( + workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, + ): Promise { + const upstream = AbortSignal.any([signal, this.lifetime.signal]) + const operation = this.convert(workspaceFileScope, path, priority, upstream) + this.active.add(operation) + try { return await operation } finally { this.active.delete(operation) } + } + + /** + * 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(signal: AbortSignal): OfficeToPdfGeneration { signal.throwIfAborted(); return this.ctx.officeToPdf.generation } + + private async convert( + scope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, + ): Promise { + try { + signal.throwIfAborted() + const extension = extname(path).slice(1).toLowerCase() + if (extension !== 'doc' && extension !== 'docx' && extension !== 'xls' + && extension !== 'xlsx' && extension !== 'ppt' && extension !== 'pptx') { + throw new OfficeToPdfError('unsupported-format', 'The path must end in doc, docx, xls, xlsx, ppt, or pptx.') + } + const authorized = await this.ctx.workspaceFiles.readBytes(scope, path, { offset: 0, length: 1 }, signal) + const source = await this.ctx.workspaceFiles.stat(scope, path, signal) + if (authorized.absolutePath !== source.absolutePath || authorized.version !== source.version) { + throw new OfficeToPdfError('source-changed', 'The source changed during authorization.') + } + signal.throwIfAborted() + const result = await this.ctx.officeToPdf.convert({ extension, priority, source: { + key: brandString(JSON.stringify([scope.sessionId, scope.workspaceRoot, source.absolutePath])), + version: source.version, ...(source.bytes === undefined ? {} : { bytes: source.bytes }), + read: async (upstream, maxBytes) => { + const loaded = await this.ctx.workspaceFiles.readAllBounded(scope, path, maxBytes, upstream).catch(async (cause: unknown) => { + if (cause instanceof RemoteError && cause.code === 'workspace-file/too-large') { + const current = await this.ctx.workspaceFiles.stat(scope, path, upstream) + if (current.absolutePath !== source.absolutePath || current.version !== source.version) { + throw new OfficeToPdfError('source-changed', 'The source changed during reading.') + } + } + throw cause + }) + upstream.throwIfAborted() + const after = await this.ctx.workspaceFiles.stat(scope, path, upstream) + if (loaded.absolutePath !== source.absolutePath || after.absolutePath !== source.absolutePath + || after.version !== source.version) { + throw new OfficeToPdfError('source-changed', 'The source changed during reading.') + } + return { bytes: Buffer.from(loaded.data, 'base64'), version: loaded.version } + }, + } }, signal) + signal.throwIfAborted() + return { absolutePath: source.absolutePath, version: source.version, + offset: 0, eof: true, bytes: result.pdf.byteLength, data: Buffer.from(result.pdf).toString('base64'), missingFonts: result.missingFonts, generation: result.generation } + } catch (cause) { + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'The document preview was cancelled.', {}, { cause }) + if (cause instanceof OfficeToPdfError) { + throw new RemoteError('document-render/failed', 'Office conversion failed.', { reason: cause.code }, { cause }) + } + throw cause + } + } +} + +export default DocumentRenderController diff --git a/packages/api/document-render-controller/src/types.ts b/packages/api/document-render-controller/src/types.ts new file mode 100644 index 0000000000..038e6097bf --- /dev/null +++ b/packages/api/document-render-controller/src/types.ts @@ -0,0 +1,17 @@ +/** Browser-safe complete PDF bytes with source identity and font notices. */ +import type { WorkspaceFileBytes } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { OfficeToPdfErrorCode, OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types' +import type {} from '@deepseek-ai/dsh-typert-protocol' + +/** PDF content carries the original Office file's absolute path and version. */ +export interface RenderedDocumentBytes extends WorkspaceFileBytes { + readonly missingFonts: string[] + readonly generation: OfficeToPdfGeneration +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The source was authorized, but its conversion failed. */ + 'document-render/failed': { readonly reason: OfficeToPdfErrorCode } + } +} diff --git a/packages/api/document-render-controller/tests/apply.client.spec.ts b/packages/api/document-render-controller/tests/apply.client.spec.ts new file mode 100644 index 0000000000..8087ce5ad4 --- /dev/null +++ b/packages/api/document-render-controller/tests/apply.client.spec.ts @@ -0,0 +1,28 @@ +/** The document Remote is available only while its Client plugin owns the mount. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' + +const descriptor = vi.hoisted(() => ({ package: '@deepseek-ai/dsh-api-document-render-controller', descriptors: [] })) +vi.mock('@deepseek-ai/dsh-api-document-render-controller/remote', () => ({ default: descriptor })) +import * as client from '../src/client/index.ts' + +it('waits for the Remote carrier, mounts its descriptor, and joins namespace disposal', async () => { + const ctx = new Context() + const released = Promise.withResolvers() + const dispose = vi.fn(() => released.promise) + const mount = vi.fn(async () => dispose) + const fiber = ctx.plugin(client) + try { + expect(mount).not.toHaveBeenCalled() + ctx.provide('remote', { $mount: mount } as never) + await fiber.await() + expect(mount).toHaveBeenCalledExactlyOnceWith(descriptor) + let closed = false + const closing = fiber.dispose().then(() => { closed = true }) + await expect.poll(() => dispose.mock.calls.length).toBe(1) + expect(closed).toBe(false) + released.resolve(undefined) + await closing + expect(closed).toBe(true) + } finally { released.resolve(undefined); await fiber.dispose() } +}) diff --git a/packages/api/document-render-controller/tests/controller.spec.ts b/packages/api/document-render-controller/tests/controller.spec.ts new file mode 100644 index 0000000000..0733cf4669 --- /dev/null +++ b/packages/api/document-render-controller/tests/controller.spec.ts @@ -0,0 +1,176 @@ +/** Authorized source identity and cancellation survive Host rendering. */ +import { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' +import { OfficeToPdfError, OfficeToPdfGeneration, OfficeToPdfKey, type OfficeToPdf, type OfficeToPdfResult } from '@deepseek-ai/dsh-office-to-pdf' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import DocumentRenderController from '../src/index.ts' + +const scope = { sessionId: SessionId('document-test'), workspaceRoot: '/workspace' } +const source = { absolutePath: '/workspace/report.DOCX', version: 'source-v1', offset: 0, eof: true, bytes: 4, data: 'UEsDBA==' } +const generation = OfficeToPdfGeneration('test-generation') +const cacheKey = OfficeToPdfKey('test-result') +const pdf = new Uint8Array([37, 80, 68, 70, 45]) +let ctx: Context +let read: ReturnType> +let authorize: ReturnType> +let metadata: ReturnType> +let render: ReturnType> + +beforeEach(async () => { + ctx = new Context() + read = vi.fn().mockResolvedValue(source) + authorize = vi.fn().mockResolvedValue(source) + metadata = vi.fn().mockResolvedValue(source) + render = vi.fn().mockImplementation(async (request, signal) => { + await request.source.read(signal!, 4) + return { pdf, missingFonts: ['Missing Serif'], generation, cacheKey } + }) + ctx.provide('workspaceFiles', { stat: metadata, readAllBounded: read, readBytes: authorize } as never) + ctx.provide('officeToPdf', { convert: render, generation } as never) + await ctx.plugin(DocumentRenderController) +}) +afterEach(async () => { await ctx.fiber.dispose() }) + +it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s bytes and returns the PDF under source identity', async (extension) => { + const path = `report.${extension.toUpperCase()}` + const result = await ctx.documentRenderController.render(scope, path, 'foreground', new AbortController().signal) + expect(read).toHaveBeenCalledExactlyOnceWith(scope, path, 4, expect.any(AbortSignal)) + expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(render).toHaveBeenCalledOnce() + expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } }) + expect(result).toEqual({ ...source, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) + expect(ctx.get('agents')).toBeUndefined() +}) + +it('preserves authorization failures without starting conversion', async () => { + const failure = new RemoteError('workspace-file/not-found', 'File missing', { path: 'report.DOCX' }) + metadata.mockRejectedValueOnce(failure) + await expect(ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal)).rejects.toBe(failure) + expect(render).not.toHaveBeenCalled() +}) + +it('keeps an unknown source size absent so the provider can reserve its input limit', async () => { + const { bytes: _bytes, ...unknownSize } = source + metadata.mockResolvedValue(unknownSize) + await ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal) + expect(render.mock.calls[0]![0].source).not.toHaveProperty('bytes') + expect(read).toHaveBeenCalledExactlyOnceWith(scope, 'report.docx', 4, expect.any(AbortSignal)) +}) + +it('refuses a cached PDF when source metadata remains readable but content access is denied', async () => { + let cached: OfficeToPdfResult | undefined + render.mockImplementation(async (request, signal) => { + if (cached !== undefined) return cached + await request.source.read(signal!, 4) + cached = { pdf, missingFonts: [], generation, cacheKey } + return cached + }) + await ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal) + const failure = new Error('read denied') + authorize.mockRejectedValueOnce(failure) + await expect(ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal)).rejects.toBe(failure) + expect(render).toHaveBeenCalledOnce() + expect(read).toHaveBeenCalledOnce() +}) + +it('refuses a source replaced while checking read access before cache lookup', async () => { + metadata.mockResolvedValueOnce({ ...source, version: 'v2' }) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } }) + expect(render).not.toHaveBeenCalled() +}) + +it('rejects unsupported extensions before reading a source', async () => { + await expect(ctx.documentRenderController.render(scope, 'report.odt', 'foreground', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'unsupported-format' } }) + expect(read).not.toHaveBeenCalled() +}) + +it.each(['invalid-document', 'output-too-large', 'timeout', 'invalid-output'] as const)('maps %s without returning source or diagnostic bytes', async (code) => { + render.mockRejectedValueOnce(new OfficeToPdfError(code, 'private engine detail')) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', message: 'Office conversion failed.', details: { reason: code } }) +}) + +it('cancels before reading and refuses a late authorized read after cancellation', async () => { + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', AbortSignal.abort())).rejects.toMatchObject({ code: 'gateway/cancelled' }) + expect(read).not.toHaveBeenCalled() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + read.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise }) + const controller = new AbortController() + const work = ctx.documentRenderController.render(scope, 'report.docx', 'foreground', controller.signal) + const rejected = expect(work).rejects.toMatchObject({ code: 'gateway/cancelled' }) + await entered.promise + controller.abort() + release.resolve(source) + await rejected + expect(render).toHaveBeenCalledOnce() +}) + +it('cancels and joins an outstanding conversion when the controller unloads', async () => { + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + render.mockImplementationOnce((_input, signal) => { entered.resolve(signal!); return release.promise }) + const work = ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal) + const rejected = expect(work).rejects.toMatchObject({ code: 'gateway/cancelled' }) + const signal = await entered.promise + let disposed = false + const closing = ctx.fiber.dispose().then(() => { disposed = true }) + try { + await vi.waitFor(() => { expect(signal.aborted).toBe(true) }) + expect(disposed).toBe(false) + } finally { release.resolve({ pdf, missingFonts: [], generation, cacheKey }); await rejected; await closing } +}) + +it('exposes the current renderer generation and refuses busy work before loading source bytes', async () => { + expect(ctx.documentRenderController.generation(new AbortController().signal)).toBe(generation) + render.mockRejectedValueOnce(new OfficeToPdfError('busy', 'capacity')) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'background', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'busy' } }) + expect(metadata).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() +}) + +it('refuses bytes when the source changes during the bounded read', async () => { + metadata.mockResolvedValueOnce(source).mockResolvedValueOnce({ ...source, version: 'v2' }) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } }) +}) + +it('classifies a grown queued source as changed without increasing its read reservation', async () => { + const failure = new RemoteError('workspace-file/too-large', 'File exceeds 4 bytes.', { path: 'report.docx', limit: 4 }) + read.mockRejectedValueOnce(failure) + metadata.mockResolvedValueOnce(source).mockResolvedValueOnce({ ...source, bytes: 8, version: 'v2' }) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)) + .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } }) + expect(read).toHaveBeenCalledExactlyOnceWith(scope, 'report.docx', 4, expect.any(AbortSignal)) +}) + +it('preserves a genuine bounded-read size failure when source identity is unchanged', async () => { + const failure = new RemoteError('workspace-file/too-large', 'File exceeds configured size cap.', { path: 'report.docx', limit: 4 }) + read.mockRejectedValueOnce(failure) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure) + expect(metadata).toHaveBeenCalledTimes(2) +}) + +it.each([ + new Error('Source read denied'), + new RemoteError('workspace-file/not-found', 'Source missing', { path: 'report.docx' }), +])('preserves a non-size bounded-read failure without another metadata query: %s', async (failure) => { + read.mockRejectedValueOnce(failure) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure) + expect(metadata).toHaveBeenCalledOnce() +}) + +it.each([ + new Error('Current source metadata denied'), + new RemoteError('workspace-file/not-found', 'Current source missing', { path: 'report.docx' }), +])('preserves a source metadata failure during the size recheck: %s', async (failure) => { + read.mockRejectedValueOnce(new RemoteError('workspace-file/too-large', 'File exceeds its reservation.', { path: 'report.docx', limit: 4 })) + metadata.mockResolvedValueOnce(source).mockRejectedValueOnce(failure) + await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure) + expect(metadata).toHaveBeenCalledTimes(2) +}) diff --git a/packages/api/document-render-controller/tsconfig.client.json b/packages/api/document-render-controller/tsconfig.client.json new file mode 100644 index 0000000000..ce459cc3b5 --- /dev/null +++ b/packages/api/document-render-controller/tsconfig.client.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/types.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../document/office-to-pdf" + }, + { + "path": "../workspace-files/tsconfig.client.json" + }, + { + "path": "../gateway/tsconfig.client.json" + } + ] +} diff --git a/packages/api/document-render-controller/tsconfig.host.json b/packages/api/document-render-controller/tsconfig.host.json new file mode 100644 index 0000000000..34c10ec2d4 --- /dev/null +++ b/packages/api/document-render-controller/tsconfig.host.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../document/office-to-pdf" + }, + { + "path": "../workspace-files/tsconfig.host.json" + }, + { + "path": "../../util/brand" + } + ], + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/client" + ] +} diff --git a/packages/api/document-render-controller/tsconfig.json b/packages/api/document-render-controller/tsconfig.json new file mode 100644 index 0000000000..ed29476453 --- /dev/null +++ b/packages/api/document-render-controller/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.host.json" }, { "path": "./tsconfig.client.json" }] +} diff --git a/packages/api/document-render-controller/tsdown.config.ts b/packages/api/document-render-controller/tsdown.config.ts new file mode 100644 index 0000000000..f5d4317abf --- /dev/null +++ b/packages/api/document-render-controller/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-document-render-controller', ['lib/types/index.js'], { hostPhase: true }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 13322367f4..a6391d20f1 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: f733218e857eaf42a8fb2779646b941c5cc1bd4f -README.zh.md: 5c058067c6f849486229e9e400109565eccc3aea +README.md: 2e33bc6fd2822e5f86e75c90ee27e544a3a52e97 +README.zh.md: 1d3eae9c4cf8a7e9946d27fd1088428bf2627276 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index f733218e85..2e33bc6fd2 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, 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 bundle is one patch plus one runtime glue plugin. 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, trust sampling, prompt sections, 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 separate `document-render-controller` row authorizes preview reads, while Document Preview owns the Office viewer and Client cache. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 5c058067c6..1d3eae9c4c 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 独立的 `document-render-controller` 条目负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index edae44c230..5f2207da5d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -229,7 +229,10 @@ name: '@deepseek-ai/dsh-office-to-pdf' # The right Sidebar's document tab: bounded file reads with selectable - # Markdown, code, HTML, PDF, and plain-text renderers. + # Markdown, code, HTML, PDF, Office, and plain-text renderers. + - id: document-render-controller + name: '@deepseek-ai/dsh-api-document-render-controller' + - id: ui-sidebar-documentpreview name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index ee0672cb39..8b8c89dd73 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -122,7 +122,8 @@ "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-office-to-pdf": "workspace:^" + "@deepseek-ai/dsh-office-to-pdf": "workspace:^", + "@deepseek-ai/dsh-api-document-render-controller": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -132,8 +133,16 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/libreoffice-kit": "0.0.1" } } diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts new file mode 100644 index 0000000000..5fc521c820 --- /dev/null +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -0,0 +1,131 @@ +/** The Web bundle's Office rows retain independent configuration and Session file authorization. */ +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +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 WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' +import DocumentRenderController from '@deepseek-ai/dsh-api-document-render-controller' +import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf' +import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { FsError } from '@deepseek-ai/dsh-fs' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' +import { expect, it, onTestFinished, vi } from 'vitest' + +const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) +vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) + +it('loads the shipped Office rows with separately patched settings and authorized PDF output', async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-office-'))) + const ctx = new Context() + onTestFinished(async () => { + try { await ctx.fiber.dispose() } + finally { vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) } + }) + const configPath = join(directory, 'cordis.yml') + const expectedRows = { + 'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf', + 'document-render-controller': '@deepseek-ai/dsh-api-document-render-controller', + '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))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id !== undefined && Object.hasOwn(expectedRows, row.id)) + expect(rows.map(row => [row.id, row.name])).toEqual(Object.entries(expectedRows)) + 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: 'ui-sidebar-documentpreview', config: clientConfig }, + ], (message) => { throw new Error(message) }) + expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig) + await writeFile(configPath, JSON.stringify([ + { name: '@deepseek-ai/dsh-session' }, + { name: '@deepseek-ai/dsh-session-projection' }, + { name: '@deepseek-ai/dsh-sandbox-policy', config: { workspaceRoot: directory } }, + { name: '@deepseek-ai/dsh-fs-local', config: { cwd: directory } }, + { name: '@deepseek-ai/dsh-typert-registry' }, + { name: '@deepseek-ai/dsh-api-workspace-files' }, + ...configured, + ])) + const pdf = Buffer.from('%PDF-1.7\nLoader preview\n%%EOF\n') + const render = vi.fn().mockImplementation(async ({ inputPath, outputPath }) => { + expect(await readFile(inputPath)).toEqual(Buffer.from('authorized OOXML')) + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: ['Missing Serif'] } + }) + kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} }) + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + // Loader's native imports must share the test's source-plane Service classes. + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@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-api-document-render-controller', DocumentRenderController], + ['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry])) + for (const row of configured) await entries.get(row.id)!.fiber!.await() + const injections: IndexInjection[] = [] + ctx.emit('webserver/index-inject', injections) + expect(injections).toEqual([ + { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig }, + ]) + + const id = SessionId('office-loader') + const session = ctx.sessions.create(id, { meta: { cwd: directory } }) + const lookup = ctx.typert.lookups.get('workspaceFileScope')! + const scope = await lookup.resolve(id) as WorkspaceFileScope | undefined + if (scope === undefined) throw new Error('Expected the Session workspace scope') + expect(await lookup.resolve(SessionId('missing'))).toBeUndefined() + const sourcePath = join(directory, 'report.docx') + await writeFile(sourcePath, 'authorized OOXML') + const signal = new AbortController().signal + const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version + const before = session.seq + const result = await ctx.documentRenderController.render(scope, 'report.docx', 'foreground', signal) + expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length, + data: pdf.toString('base64'), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation }) + const readAgain = vi.spyOn(ctx.workspaceFiles, 'readAllBounded') + expect(await ctx.documentRenderController.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.documentRenderController.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.documentRenderController.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal) + if (process.platform !== 'win32') { + await symlink(sourcePath, join(directory, 'link.docx')) + await expect(ctx.documentRenderController.render(scope, 'link.docx', 'foreground', signal)) + .rejects.toMatchObject({ code: 'workspace-file/not-regular-file' }) + } + expect(render).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 30c307c216..b109b1affc 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -99,7 +99,7 @@ "path": "../ui-workspace" }, { - "path": "../ui-sidebar-documentpreview" + "path": "../ui-sidebar-documentpreview/tsconfig.client.json" }, { "path": "../ui-input-trigger" diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6bf77e5972..c6928a55a7 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: d4e114aa6e12171d61f8a5284fdbe923916fe0d9 -README.zh.md: 32cb704d29fd0009fdf7a51235617c25029295f0 +README.md: 165fa2e456963af938a27fbf0fe3c0717e7ffd50 +README.zh.md: 7ce25e59038177241248ce90b8cc33848b77b81a diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d4e114aa6e..165fa2e456 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -1,5 +1,5 @@ --- -description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback." +description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF, Office and HTML renderers, and plain-text fallback." kind: "package-reference" --- @@ -9,13 +9,14 @@ English | [中文](README.zh.md) ## Summary -Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. +Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. Office documents convert locally to PDF. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. ## Table of Contents - [What it registers](#what-it-registers) - [Addresses](#addresses) - [How it reads](#how-it-reads) +- [Office preview](#office-preview) - [Navigation](#navigation) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -27,10 +28,12 @@ Preview readable files in the right Sidebar and choose among registered renderer ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. -Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, supportsText?, wrap?, bodyId?, read? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. `supportsText(path)` overrides plain-text availability, which defaults to enabled for text pages and disabled for complete bytes. HTML and SVG opt in; unmatched extensions retain the plain-text fallback. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. A complete-byte renderer may supply `read` to prepare content and `bodyId` to reuse an existing keyed body. The reader preserves the source path and version; changing readers invalidates cached bytes, and reload or tab closure cancels the active read. [Office previews](#office-preview) use this path to deliver locally converted PDFs. + +Converting readers can annotate byte content with missing font families. A keyed `sidebar.right.tab.document.notice` slot above the scrollport uses the selected reader id; the reader owns its notice while the PDF body remains shared. ## Addresses @@ -51,9 +54,37 @@ HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-s PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. -Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. +Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. -Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. +Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. + + +## Office preview + +Open `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion; invalid 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. + +| Field | Default | Meaning | +|---|---|---| +| `office.maxCachedEntries` | `8` | Maximum retained completed PDFs | +| `office.maxCachedBytes` | `67108864` (64 MiB) | Maximum retained binary PDF bytes, counted by buffer `byteLength` | +| `office.maxPending` / `office.maxReaders` | `8` / `32` | Unsettled conversion RPCs / readers including metadata lookups | + +Opening an Office file requests conversion on demand. Each read checks renderer generation and authorized source metadata before sharing an in-flight conversion or reusing a successful PDF. The cache uses renderer generation, Session, absolute source path, and source version as its identity; least-recently used PDFs leave to keep retention within both limits. Failures and PDFs larger than the byte limit are not retained. A converted PDF is retained only when its returned renderer generation matches the cache identity. Cancelling one reader leaves shared conversion running until the final reader leaves. Connection resets clear cached bytes and cancel pending reads; plugin disposal also waits for outstanding requests. PDFs are never persisted; transport strings, decoding storage, and PDF.js rendering memory remain outside the cache limit. + +Background requests leave the final pending-request and reader slots available for foreground work; setting either limit to one rejects background reads. Renderer replacement restarts generation discovery and authorization once. Another replacement during that retry reports the localized busy message. + +A yellow notice identifies fonts unavailable during conversion. Show more opens an anchored list; closing the list preserves the notice. Dismissing the notice collapses its space and moves the PDF upward. Dismissal applies to the same source version while the preview remains mounted; switching away from the tab and back shows the notice again. Long notice text fades before the action, and reduced-motion preferences disable the collapse animation. + +
+Office implementation — click to expand + +Office registration, caching, and font notices live in `src/client/office/` and reuse the shared PDF body. The registration remains available without the Host renderer. Injected `remote.documentRender` and `remote.workspaceFiles` namespaces supply conversion and freshness callbacks; their removal restores the unavailable reader. Registry, locale, and notice registrations are reversible effects. Office UI shares this package's Loader lifetime; the [conversion provider](../../document/office-to-pdf/README.md) and [Remote controller](../../api/document-render-controller/README.md) remain independently configurable plugins. + +The shared `documentFileBytes()` helper decodes ordinary file and converted PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. + +
## Navigation @@ -73,6 +104,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. +- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc`, `.xls`, and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 32cb704d29..7ce25e5903 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -1,5 +1,5 @@ --- -description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。" +description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF、Office 和 HTML 渲染器,并以纯文本兜底。" kind: "package-reference" --- @@ -9,13 +9,14 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 +在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 ## 目录 - [注册了什么](#what-it-registers) - [地址](#addresses) - [怎么读](#how-it-reads) +- [Office 预览](#office-preview) - [导航](#navigation) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) @@ -27,10 +28,12 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 -文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中字节不可按文本阅读的后缀;匹配这类后缀的文件在其查看器选项中不再提供纯文本兜底。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 +文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, supportsText?, wrap?, bodyId?, read? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。`supportsText(path)` 可覆盖纯文本可用性,默认对文本页启用、对完整字节禁用。HTML 和 SVG 显式启用;未匹配的扩展名保留纯文本回退。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。完整字节渲染器可提供 `read` 来准备内容,并通过 `bodyId` 复用已有的 keyed 正文。读取器保留源文件路径和版本;切换读取器会使缓存字节失效,重新载入或关闭 tab 会取消当前读取。[Office 预览](#office-preview) 通过此路径提供本地转换的 PDF。 + +转换读取器可在字节内容中附带缺失字体族。滚动区域上方的 keyed `sidebar.right.tab.document.notice` Slot 使用所选读取器 id;读取器负责自己的提示,PDF 正文仍然共享。 ## 地址 @@ -51,9 +54,37 @@ HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbo PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 -共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。 +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。 -首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 +首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 + + +## Office 预览 + +将 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换;无效文件、转换失败和超时会显示本地化消息。缺少 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 后重新加载浏览器页面。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `office.maxCachedEntries` | `8` | 最多保留的已完成 PDF 数量 | +| `office.maxCachedBytes` | `67108864`(64 MiB) | 最多保留的二进制 PDF 字节数,按缓冲区 `byteLength` 计算 | +| `office.maxPending` / `office.maxReaders` | `8` / `32` | 未完成转换 RPC 数量 / 含元数据查询的读取方数量 | + +打开 Office 文件时按需请求转换。每次读取都先检查渲染 generation 和已授权的源文件元数据,再共享进行中的转换或复用成功的 PDF。缓存以渲染 generation、Session、源文件绝对路径和源版本作为身份;通过淘汰最久未使用的 PDF,使保留量符合两项限制。失败和超过字节限制的 PDF 不会被保留。转换后 PDF 的返回渲染 generation 与缓存身份一致时,才会保留该 PDF。取消一个读取方后,共享转换会继续运行,直到最后一个读取方离开。连接重置会清空缓存字节并取消待完成读取;插件卸载还会等待未结束的请求完成。PDF 从不持久化;传输字符串、解码存储和 PDF.js 渲染内存不计入缓存限制。 + +后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。 + +黄色提示条说明转换时不可用的字体。“显示更多”打开锚定字体列表;关闭列表保留提示条。关闭提示条会收起其占位并使 PDF 上移,关闭状态仅在预览持续挂载且源文件版本相同时保留;切换到其他标签再切回会重新显示提示条。过长提示文字在操作按钮前渐隐,减少动态效果偏好会禁用收起动画。 + +
+Office 实现——点击展开 + +Office 注册、缓存和字体提示位于 `src/client/office/`,复用共享 PDF 正文。Host 渲染器缺失时,注册仍然可用。注入的 `remote.documentRender` 和 `remote.workspaceFiles` 命名空间提供转换与版本检查回调;移除后恢复不可用读取器。注册表、本地化和提示注册均为可撤销的 effect。Office UI 与本包共享 Loader 生命周期;[转换提供方](../../document/office-to-pdf/README.zh.md)和 [Remote 控制器](../../api/document-render-controller/README.zh.md)仍为可独立配置的插件。 + +共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 + +
## 导航 @@ -73,6 +104,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 +- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc`、`.xls` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index d236ab1ceb..522621ab9c 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", - "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text", + "description": "Extensible document previews for Sidebar files: Office, Markdown, highlighted code, images, PDF, HTML, and plain text", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -32,7 +32,8 @@ "@deepseek-ai/dsh-api-workspace-files", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale" ], "platform": "web" } @@ -47,9 +48,11 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-document-render-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", @@ -60,6 +63,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", @@ -68,12 +72,16 @@ "clsx": "^2.0.0", "pdfjs-dist": "6.3.289", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^" }, "files": [ "lib/index.js", "lib/client.js", "lib/client.*.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 4c8fd719be..46757a6287 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -107,6 +107,10 @@ overflow: hidden; } +.body:has([data-pdf-preview]) { + background: var(--dsw-alias-bg-document-preview); +} + .wrap { white-space: pre-wrap; word-break: break-word; @@ -308,10 +312,12 @@ background: var(--dsw-alias-interactive-bg-hover); } .viewerTool { + flex: none; width: auto; max-width: 160px; padding: 0 6px; overflow: hidden; + color: var(--dsw-alias-label-secondary); font-size: 12px; white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 9430ec53a8..c58c2c42db 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -82,7 +82,7 @@ export interface TextPreviewInjected extends TextInjected { /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ export type TextPreviewProps = & PropsRuntime<'sidebar.right.pane.tab'> - & PropsRenderSlots<'sidebar.right.tab.document'> + & PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.notice'> & PropsStore & InjectFace & PropsLocale<'sidebarDocumentPreview'> @@ -109,11 +109,14 @@ export function TextPreview({ if (matched.length > 0 && binaryDocumentPath(definitions, file.path)) return matched if (matched.length === 0 && unviewable) return matched const fallback = definitions.find(definition => definition.id === PLAIN_BODY_ID) - return fallback === undefined ? matched : [...matched, fallback] + const supportsText = matched.length === 0 + || matched.some(definition => definition.supportsText?.(file.path) ?? definition.loading === 'text-pages') + return fallback === undefined || !supportsText ? matched : [...matched, fallback] }, [definitions, file.path, unviewable]) const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0] const mode = selected?.loading - const current = (state?.mode ?? 'text-pages') === mode ? state : undefined + const readerId = selected?.read === undefined ? undefined : selected.id + const current = (state?.mode ?? 'text-pages') === mode && state?.readerId === readerId ? state : undefined const bodyRef = useRef(null) const scrollportRef = useRef(null) const storedScrollTopRef = useRef(0) @@ -147,8 +150,8 @@ export function TextPreview({ useEffect(() => { if (started || !canRead || mode === undefined) return if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version) - else loadAll(tab.id, file, signal, meta.value?.version) - }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version]) + else loadAll(tab.id, file, signal, meta.value?.version, selected) + }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, selected, meta.value?.version]) // Come back where the reader was once there is content to scroll: on a remount, // after a reload rebuilt the content, or after the selected renderer changed. @@ -188,7 +191,9 @@ export function TextPreview({ const content = useMemo((): DocumentContent | undefined => { if (mode === 'bytes-complete') { - return current?.complete === undefined ? undefined : { kind: 'bytes', data: current.complete.data } + return current?.complete === undefined ? undefined : { + kind: 'bytes', data: current.complete.data, missingFonts: current.complete.missingFonts, + } } if (current === undefined || loaded.length === 0) return undefined return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof } @@ -233,7 +238,7 @@ export function TextPreview({ const reload = (): void => { if (!canRead) return if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version) - else reloadAll(tab.id, file, signal, meta.value?.version) + else reloadAll(tab.id, file, signal, meta.value?.version, selected) } return (
@@ -316,6 +321,9 @@ export function TextPreview({
+ {content !== undefined && renderSlot('sidebar.right.tab.document.notice', { + resourceAddress: tab.contentId, sourceVersion: current?.complete?.version, content, + }, { entryKey: selected.id })}
{t('rendererUnavailable', { name: selected.title() })}

, })} {current?.failure !== undefined && (hasContent diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts index 11f7cd1c24..d757486085 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts @@ -16,10 +16,20 @@ export interface DocumentTextPage { */ export type DocumentContent = | { readonly kind: 'text'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean } - | { readonly kind: 'bytes'; readonly data: Uint8Array } + | { readonly kind: 'bytes'; readonly data: Uint8Array; readonly missingFonts?: readonly string[] | undefined } declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Renderer-owned notice above the document scrollport, selected by the reader id. */ + 'sidebar.right.tab.document.notice': { + kind: 'keyed' + scope: 'session' + owner: { + readonly resourceAddress: string + readonly sourceVersion: string | undefined + readonly content: DocumentContent + } + } /** Document body selected by a registered implementation id. */ 'sidebar.right.tab.document': { kind: 'keyed' diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts index ac10289bd1..7afaa320c8 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts @@ -1,4 +1,5 @@ /** File-extension preview registrations; component dispatch belongs to the keyed document slot. */ +import type { ReadDocumentBytes } from '../rpc.ts' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { documentFileName, matchedSuffixLength, normalizeSuffix } from './suffix.ts' @@ -23,8 +24,18 @@ export interface DocumentPreviewDefinition { readonly title: () => string /** Content delivery mode supplied by the document owner. */ readonly loading: DocumentLoadMode + /** + * Whether the source supports plain-text viewing; defaults to true for text-pages and false for bytes-complete. + * @param path - decoded source file path. + * @returns whether the toolbar may offer the plain-text fallback. + */ + readonly supportsText?: (path: string) => boolean /** Whether the implementation consumes the document's wrap preference. */ readonly wrap?: boolean + /** Existing keyed document body to reuse; omitted uses this registration’s id. */ + readonly bodyId?: string + /** Prepare complete bytes through a renderer-owned read; source identity and version must be preserved. */ + readonly read?: ReadDocumentBytes } /** diff --git a/packages/client/ui-sidebar-documentpreview/src/client/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/face.ts index 10ad3049ce..de793f5dcf 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/face.ts @@ -20,9 +20,8 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts' -import { documentFileBytes } from './rpc.ts' import type { TextStore } from './store.ts' -import type { DocumentLoadMode } from './document/registry.ts' +import type { DocumentLoadMode, DocumentPreviewDefinition } from './document/registry.ts' /** The preview's injected business face, as the body receives it. */ export interface TextInjected { @@ -50,20 +49,26 @@ export interface TextInjected { readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void /** * Read the complete file for a whole-file renderer. + * @param reader - optional renderer-owned conversion and identity. * @param tabId - owning tab. * @param file - the session and workspace path the tab's address names. * @param signal - tab lifetime. * @param observedVersion - metadata version observed at read start. */ - readonly loadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + readonly loadAll: ( + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition, + ) => void /** * Discard the old complete result and read again. + * @param reader - optional renderer-owned conversion and identity. * @param tabId - owning tab. * @param file - the session and workspace path the tab's address names. * @param signal - tab lifetime. * @param observedVersion - metadata version observed at read start. */ - readonly reloadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + readonly reloadAll: ( + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition, + ) => void } /** @@ -75,6 +80,8 @@ interface TabReads { generation: number version: string | undefined mode: DocumentLoadMode + readerId?: string + controller?: AbortController } /** @@ -97,14 +104,18 @@ export function textFace( const created: TabReads = { generation: 0, version: undefined, mode: 'text-pages' } tabs.set(tabId, created) signal.addEventListener('abort', () => { + created.controller?.abort() tabs.delete(tabId) actions.forget(tabId) }, { once: true }) return created } - const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode): TabReads => { + const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode, readerId?: string): TabReads => { const reads = readsOf(tabId, signal) - if (reads.mode !== mode) { + if (reads.mode !== mode || reads.readerId !== readerId) { + reads.controller?.abort() + if (readerId === undefined) delete reads.readerId + else reads.readerId = readerId reads.mode = mode reads.generation++ reads.version = undefined @@ -133,45 +144,49 @@ export function textFace( actions.page(tabId, result.value) }) } - const loadAll = (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string): void => { + const loadAll = ( + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition, + ): void => { if (signal.aborted) return - const reads = modeOf(tabId, signal, 'bytes-complete') - const { generation } = reads - actions.loading(tabId, 'bytes-complete', observedVersion) - void readAll(file, signal).then((result) => { - if (signal.aborted || reads.generation !== generation) return + const readerId = reader?.read === undefined ? undefined : reader.id + const reads = modeOf(tabId, signal, 'bytes-complete', readerId) + reads.controller?.abort() + const controller = new AbortController() + reads.controller = controller + const lifetime = AbortSignal.any([signal, controller.signal]) + actions.loading(tabId, 'bytes-complete', observedVersion, readerId) + void (reader?.read ?? readAll)(file, lifetime).then((result) => { + if (lifetime.aborted) return if (!result.ok) { actions.failed(tabId, result.error) return } - let file - try { - file = documentFileBytes(result.value) - } catch (error) { - actions.failed(tabId, Object.assign( - new Error('document file byte response has malformed base64 data', { cause: error }), - { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} }, - )) - return - } + const file = result.value reads.version = file.version actions.complete(tabId, file) + }, (error: unknown) => { + if (lifetime.aborted) return + actions.failed(tabId, Object.assign( + new Error(error instanceof Error ? error.message : String(error), { cause: error }), + { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} }, + )) }) } const restart = ( - tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages', + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages', reader?: DocumentPreviewDefinition, ): void => { if (signal.aborted) return const reads = readsOf(tabId, signal) + reads.controller?.abort() reads.generation += 1 reads.version = undefined actions.reset(tabId) if (mode === 'text-pages') loadPage(tabId, file, 1, signal, observedVersion) - else loadAll(tabId, file, signal, observedVersion) + else loadAll(tabId, file, signal, observedVersion, reader) } return { loadPage, reloadPages: restart, loadAll, - reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') }, + reloadAll: (tabId, file, signal, observedVersion, reader) => { restart(tabId, file, signal, observedVersion, 'bytes-complete', reader) }, } } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts index e6f1a201d1..826c7c9b82 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts @@ -16,7 +16,7 @@ export const HTML_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/ * @returns metadata for complete HTML documents. */ export function htmlBodyDefinition(title: () => string): DocumentPreviewDefinition { - return { id: HTML_BODY_ID, extensions: ['html', 'htm'], priority: 'builtin', title, loading: 'bytes-complete', wrap: false } + return { id: HTML_BODY_ID, extensions: ['html', 'htm'], priority: 'builtin', title, loading: 'bytes-complete', supportsText: () => true, wrap: false } } /** diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts index 51794da53a..dcac2939d3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts @@ -27,6 +27,7 @@ export function imageBodyDefinition(title: () => string): DocumentPreviewDefinit priority: 'builtin', title, loading: 'bytes-complete', + supportsText: path => /\.svg$/iu.test(path), wrap: false, } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index ef76084e07..d45cccc229 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -25,7 +25,7 @@ import type { TextPreviewInjected } from './TextPreview.tsx' import { TextTitle } from './TextTitle.tsx' import { TEXTPREVIEW_ID, textDefinition } from './definition.ts' import { textFace } from './face.ts' -import { createReadPage } from './rpc.ts' +import { createReadPage, documentFileBytes } from './rpc.ts' import { createTextStore } from './store.ts' import { en, zh } from './locales.ts' import { DocumentPreviewRegistry } from './document/registry.ts' @@ -36,6 +36,8 @@ import { apply as registerHtml } from './html/index.ts' import { apply as registerImage } from './image/index.ts' import { apply as registerPdf } from './pdf/index.ts' import { apply as registerCode } from './code/index.ts' +import { apply as registerOffice } from './office/index.ts' +import { Config } from '../config.ts' // Values stay package-private unless another package needs them; the plugin // surface is `apply`, `inject`, and the store factory another registration may @@ -43,7 +45,7 @@ import { apply as registerCode } from './code/index.ts' export type { SidebarDocumentPreviewKey } from './locales.ts' export type { TextPreviewProps } from './TextPreview.tsx' export type { TextInjected } from './face.ts' -export type { ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts' +export type { ReadDocumentBytes, DocumentFileBytes, ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts' export type { TextPage, TextState, TextStore, TextTabState } from './store.ts' export type { DocumentContent, DocumentPreviewProps, DocumentTextPage } from './document/contract.ts' export type { DocumentLoadMode, DocumentPreviewDefinition } from './document/registry.ts' @@ -83,6 +85,7 @@ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote. * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face. */ export function apply(ctx: ClientContext): void { + const config = Config((globalThis as { __DSH_DOCUMENT_PREVIEW_CONFIG__?: unknown }).__DSH_DOCUMENT_PREVIEW_CONFIG__ ?? {}) const previews = new DocumentPreviewRegistry() const disposePreviews = ctx.reflect.provide('documentPreviews', previews) ctx.effect(() => disposePreviews) @@ -92,16 +95,22 @@ export function apply(ctx: ClientContext): void { const store = createTextStore() const face = textFace( createReadPage(ctx.remote), - (file, signal) => ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal), + async (file, signal) => { + const result = await ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal) + return result.ok ? { ok: true, value: documentFileBytes(result.value) } : result + }, ) const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe } ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store, children: { + 'sidebar.right.tab.document.notice': { kind: 'keyed', scope: 'session' }, 'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } }, }, - inject: (sessionId, actions): TextPreviewInjected => ({ ...face(sessionId, actions), hooks: { documentPreviews: source } }), + inject: (sessionId, actions): TextPreviewInjected => ({ + ...face(sessionId, actions), hooks: { documentPreviews: source }, + }), }, TextPreview, )), 'ui-sidebar-documentpreview: text body') @@ -115,4 +124,5 @@ export function apply(ctx: ClientContext): void { registerImage(ctx) registerPdf(ctx) registerCode(ctx) + registerOffice(ctx, config.office) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css new file mode 100644 index 0000000000..226ae2f0fd --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -0,0 +1,139 @@ +.space { + display: grid; + flex: none; + grid-template-rows: 1fr; + background: var(--dsw-alias-bg-document-preview); + transition: grid-template-rows 180ms ease, opacity 180ms ease; +} + +.space[data-dismissed='true'] { + grid-template-rows: 0fr; + opacity: 0; + pointer-events: none; +} + +.clip { + min-height: 0; + overflow: hidden; +} + +.notice { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 40px; + margin: 12px 12px 0; + padding: 0 8px 0 12px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-state-warn-tertiary); + border: 0.5px solid var(--dsw-alias-state-warn-secondary); + border-radius: 10px; + box-shadow: var(--dsw-elevation-panel); + font-size: 12px; + line-height: 1.5; +} + +.warning, .anchor { + flex: none; +} + +.warning { + color: var(--dsw-alias-state-warn-label); +} + +.message { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.message > span { + display: block; + overflow: hidden; +} + +.message[data-clipped='true'] > span { + mask-image: linear-gradient(to right, black calc(100% - 44px), transparent calc(100% - 12px)); +} + +.message[data-clipped='true']::after { + position: absolute; + right: 0; + top: 0; + content: '...'; +} + +.notice .more, .notice .close { + flex: none; + padding: 0 4px; + color: inherit; + background: transparent; + font-size: 12px; + line-height: 1.5; +} + +.notice .more:hover, .notice .close:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.panel { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + position: fixed; + z-index: 100; + box-sizing: border-box; + width: min(340px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + padding: 16px; + overflow: auto; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-2); + border: 0; + border-radius: 12px; + box-shadow: var(--dsw-elevation-prominent); + font-size: 13px; + line-height: 1.6; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panelHeader h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.description { + margin: 8px 0 16px; + color: var(--dsw-alias-label-secondary); +} + +.count { + margin: 0 0 6px; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.fonts { + margin: 0; + padding: 0; + list-style: none; + overflow-wrap: anywhere; +} + +.fonts li { + padding: 8px 0; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +@media (prefers-reduced-motion: reduce) { + .space { transition: none; } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx new file mode 100644 index 0000000000..c52a9d62c9 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx @@ -0,0 +1,87 @@ +/** Missing-font notice and a non-modal details panel for one source version. */ +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../document/contract.ts' +import { Button, IconCloseOutline16, IconWarningOutline16, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './FontNotice.module.css' + +/** Notice inputs supplied by the document owner and Office locale registration. */ +export type FontNoticeProps = PropsRuntime<'sidebar.right.tab.document.notice'> & PropsLocale<'sidebarOffice'> + +/** + * Show missing fonts; dismissal applies to the same source version while this component stays mounted. + * @param props - source identity, converted content, and localized copy. + * @returns a collapsible notice and its anchored details, or nothing when fonts are available. + */ +export function FontNotice({ resourceAddress, sourceVersion, content, t }: FontNoticeProps): ReactNode { + const identity = JSON.stringify([resourceAddress, sourceVersion]) + const [dismissed, setDismissed] = useState() + const [expanded, setExpanded] = useState() + const fonts = content.kind === 'bytes' ? content.missingFonts ?? [] : [] + const visible = fonts.length > 0 && dismissed !== identity + const open = visible && expanded === identity + const root = useRef(null) + const anchor = useRef(null) + const panel = useRef(null) + const message = useRef(null) + const id = useId() + const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 8, margin: 12 }) + useDismissOnOutsidePointer(root, open, () => { setExpanded(undefined) }, panel) + const closeDetails = (): void => { + setExpanded(undefined) + anchor.current?.querySelector('button')?.focus() + } + const positioned = position !== null + useLayoutEffect(() => { + if (!open || !positioned) return + panel.current?.focus() + }, [open, positioned]) + useLayoutEffect(() => { + const element = message.current + if (element === null) return + const label = element.firstElementChild as HTMLSpanElement + const measure = (): void => { element.dataset.clipped = String(label.scrollWidth > element.clientWidth) } + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => { observer.disconnect() } + }, [content, t]) + if (fonts.length === 0) return null + return <> +
+
+
+ + + {t('missingFonts', { fonts: fonts.join(', ') })} + + + + +
+
+
+ {open && createPortal(, document.body)} + +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts new file mode 100644 index 0000000000..8a1946d24c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts @@ -0,0 +1,179 @@ +/** 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 { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { ReadDocumentBytes, SessionFile } from '../rpc.ts' + +/** Authorized cached reads carry explicit scheduling intent to the Host. */ +export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority) => ReturnType + +type Result = Awaited> +type Success = Extract +type Stat = (file: SessionFile, signal: AbortSignal) => Promise> +interface Pending { + readonly controller: AbortController + readonly promise: Promise + users: number +} + +/** Bounded successful results; each caller reauthorizes and checks source freshness before reuse. */ +export class OfficePreviewCache { + private readonly ready = new Map() + private readonly pending = new Map() + private bytes = 0 + private readers = 0 + private generation: OfficeToPdfGeneration | undefined + private generationQuery = 0 + private acceptedQuery = 0 + private readonly superseded = new Error() + private readonly lifetime = new AbortController() + private readonly tasks = new Set>() + private readonly reads = new Set>() + + /** + * @param stat - authorized source metadata lookup. + * @param convert - Host render Remote returning binary PDF bytes borrowed read-only by callers. + * @param maxEntries - maximum completed results retained. + * @param maxBytes - maximum retained PDF byteLength. + * @param maxPending - maximum unsettled Host conversion requests, including cancellation teardown. + * @param maxReaders - maximum readers, including metadata lookups. + * @param generation - current Host renderer generation, checked before cached reuse. + * @param busy - localized capacity failure. + */ + 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>, + private readonly busy: () => Error) {} + + /** + * Share a conversion without letting one caller cancel another caller's work. + * Renderer replacement retries authorization once; repeated replacement reports localized capacity failure. + * @param file - Session authorization scope and source path. + * @param signal - this caller's lifetime. + * @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 { + signal.throwIfAborted() + this.lifetime.signal.throwIfAborted() + const readerLimit = priority === 'background' ? this.maxReaders - 1 : this.maxReaders + if (this.readers >= readerLimit || (priority === 'background' && this.maxPending === 1)) throw this.busy() + this.readers++ + const operation = this.lookup(file, signal, priority) + this.reads.add(operation) + try { return await operation } finally { this.readers--; this.reads.delete(operation) } + } + + private async lookup(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { return await this.lookupGeneration(file, signal, priority) } + catch (error) { if (error !== this.superseded) throw error } + } + throw this.busy() + } + + private async lookupGeneration(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise { + signal = AbortSignal.any([signal, this.lifetime.signal]) + signal.throwIfAborted() + const query = ++this.generationQuery + const generation = await this.currentGeneration(signal) + signal.throwIfAborted() + if (!generation.ok) return generation + if (query < this.acceptedQuery && generation.value !== this.generation) throw this.superseded + this.acceptedQuery = Math.max(query, this.acceptedQuery) + if (generation.value !== this.generation) { + this.generation = generation.value + this.ready.clear() + this.bytes = 0 + for (const pending of this.pending.values()) pending.controller.abort(this.superseded) + this.pending.clear() + } + const metadata = await this.stat(file, signal) + signal.throwIfAborted() + if (!metadata.ok) return metadata + if (generation.value !== this.generation) throw this.superseded + const key = JSON.stringify([generation.value, file.sessionId, metadata.value.absolutePath, metadata.value.version]) + const cached = this.ready.get(key) + if (cached !== undefined) { + this.ready.delete(key) + this.ready.set(key, cached) + return cached + } + // A foreground RPC joins and promotes background work at the shared Host queue. + const pendingKey = JSON.stringify([key, priority]) + let entry = this.pending.get(pendingKey) + if (entry === undefined) { + const pendingLimit = priority === 'background' ? this.maxPending - 1 : this.maxPending + if (this.tasks.size >= pendingLimit) throw this.busy() + const controller = new AbortController() + const promise = Promise.resolve().then(() => { + controller.signal.throwIfAborted() + return this.convert(file, controller.signal, priority) + }).then((result) => { + controller.signal.throwIfAborted() + if (generation.value === this.generation && result.ok && result.value.generation === generation.value + && result.value.version === metadata.value.version + && result.value.absolutePath === metadata.value.absolutePath) { + this.retain(key, result) + } + return result + }).finally(() => { + this.tasks.delete(promise) + if (this.pending.get(pendingKey)?.controller === controller) this.pending.delete(pendingKey) + }) + this.tasks.add(promise) + entry = { controller, promise, users: 0 } + this.pending.set(pendingKey, entry) + } + const shared = entry + shared.users += 1 + return new Promise((resolve, reject) => { + let settled = false + const finish = (): boolean => { + if (settled) return false + settled = true + signal.removeEventListener('abort', abort) + shared.users -= 1 + if (shared.users === 0 && this.pending.get(pendingKey) === shared) { + this.pending.delete(pendingKey) + shared.controller.abort() + } + return true + } + const abort = (): void => { + const reason: unknown = signal.reason + finish() + reject(reason instanceof Error ? reason : new Error('Office preview cancelled', { cause: reason })) + } + signal.addEventListener('abort', abort, { once: true }) + shared.promise.then( + (result) => { if (finish()) resolve(result) }, + (error: unknown) => { if (finish()) reject(error instanceof Error ? error : new Error('Office preview failed', { cause: error })) }, + ) + }) + } + + /** Clear retained bytes, cancel outstanding conversions, and await their completion. */ + async dispose(): Promise { + this.lifetime.abort() + this.pending.clear() + this.ready.clear() + this.bytes = 0 + await Promise.allSettled([...this.reads, ...this.tasks]) + } + + private retain(key: string, result: Success): void { + const previous = this.ready.get(key) + if (previous !== undefined) { this.ready.delete(key); this.bytes -= previous.value.data.byteLength } + const size = result.value.data.byteLength + if (size > this.maxBytes) return + while (this.ready.size >= this.maxEntries || this.bytes + size > this.maxBytes) { + const oldest = this.ready.entries().next().value as [string, Success] + this.ready.delete(oldest[0]) + this.bytes -= oldest[1].value.data.byteLength + } + this.ready.set(key, result) + this.bytes += size + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts new file mode 100644 index 0000000000..3eea5f9483 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -0,0 +1,96 @@ +/** Office preview registration backed by authorized Host rendering and the existing PDF body. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-api-document-render-controller/remote' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-api-workspace-files/remote' +import type {} from '@deepseek-ai/dsh-client-connection/client' +import { documentFileBytes, type ReadDocumentBytes } from '../rpc.ts' +import { en, zh, type OfficePreviewKey } from './locales.ts' +import { OfficePreviewCache, type ReadOfficeBytes } from './cache.ts' +import { FontNotice } from './FontNotice.tsx' +import type { Config } from '../../config.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + sidebarOffice: OfficePreviewKey + } +} + +/** + * Register Office previews with versioned PDF reuse and missing-font notices. + * @param ctx - Client renderer registry, localized copy, and optional Host Remotes. + * @param config - Resolved Office preview cache limits. + */ +export function apply(ctx: Context, config: Config['office']): void { + const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office' + ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en })) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.notice', () => ctx.slots.register({ + name: 'sidebar.right.tab.document.notice', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', + locale: 'sidebarOffice', + }, FontNotice))) + const t = ctx.locale.bind('sidebarOffice') + const unavailable: ReadDocumentBytes = (_file, signal) => { + signal.throwIfAborted() + return Promise.reject(new Error(t('unavailable'))) + } + let read = unavailable + ctx.effect(() => ctx.documentPreviews.register({ + id, + bodyId: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/pdf', + extensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'], priority: 'builtin', + title: () => t('title'), loading: 'bytes-complete', wrap: false, + read: (file, signal) => read(file, signal), + })) + ctx.inject(['remote', 'remote.documentRender', 'remote.workspaceFiles'], (scope) => { + const convert: ReadOfficeBytes = async (file, signal, priority) => { + signal.throwIfAborted() + const result = await scope.remote.documentRender.render(file.sessionId, file.path, priority, signal) + signal.throwIfAborted() + if (!result.ok) { + if (result.error.code === 'document-render/failed') { + throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error }) + } + return result + } + return { ok: true, value: documentFileBytes(result.value) } + } + const createCache = () => new OfficePreviewCache( + async (file, signal) => { + const authorized = await scope.remote.workspaceFiles.readBytes(file.sessionId, file.path, { offset: 0, length: 1 }, signal) + signal.throwIfAborted() + if (!authorized.ok) return authorized + const metadata = await scope.remote.workspaceFiles.stat(file.sessionId, file.path, signal) + if (metadata.ok && (authorized.value.absolutePath !== metadata.value.absolutePath + || authorized.value.version !== metadata.value.version)) { + throw new Error(t('changed')) + } + return metadata + }, + convert, config.maxCachedEntries, config.maxCachedBytes, config.maxPending, config.maxReaders, + signal => scope.remote.documentRender.generation(signal), () => new Error(t('busy')), + ) + let cache = createCache() + const retired = new Set>() + read = (file, signal) => cache.read(file, signal) + scope.on('connection/reset', () => { + const previous = cache + cache = createCache() + const closing = previous.dispose().finally(() => { retired.delete(closing) }) + retired.add(closing) + }) + scope.effect(() => async () => { read = unavailable; await Promise.all([...retired, cache.dispose()]) }) + }) +} + +function conversionErrorKey(code: string): OfficePreviewKey { + switch (code) { + case 'input-too-large': case 'output-too-large': return 'tooLarge' + case 'invalid-document': case 'unsupported-format': return 'invalid' + case 'timeout': return 'timeout' + case 'unavailable': return 'unavailable' + case 'busy': return 'busy' + case 'source-changed': return 'changed' + default: return 'failed' + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts new file mode 100644 index 0000000000..79d9cc0e8c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -0,0 +1,40 @@ +/** Office preview copy and Host render configuration guidance. */ +export const zh = { + title: 'Office 文档', + missingFonts: '缺少文档使用的字体:{fonts},可能影响文字和排版。', + showMore: '显示更多', + dismissNotice: '关闭字体提示', + missingFontsTitle: '缺失的字体', + missingFontsDescription: '本次预览无法使用以下字体,预览中的文字和排版可能与原文档不同。', + missingFontsCount: '{count} 种字体', + closeDetails: '关闭字体详情', + unavailable: 'Office 预览不可用。请在运行 DeepSeek Harness 的主机上启用文档预览服务。', + invalid: '无法预览此 Office 文件。文件可能已损坏、受密码保护,或与扩展名不符。', + tooLarge: 'Office 文件或转换后的 PDF 超过预览大小上限,请缩小文件或调整预览配置。', + failed: 'Office 转换失败,未生成可用的 PDF。请检查该文件后重试。', + timeout: 'Office 转换超时,请重试。', + busy: 'Office 预览任务较多,请稍后重试。', + changed: '文件在读取时已更改,请重新打开预览。', +} satisfies Record + +/** Office preview locale keys. */ +export type OfficePreviewKey = keyof typeof zh + +/** English translations checked against the Chinese key set. */ +export const en = { + title: 'Office document', + missingFonts: 'Fonts used in this document are unavailable: {fonts}. Text and layout may differ.', + showMore: 'Show more', + dismissNotice: 'Dismiss font notice', + missingFontsTitle: 'Missing fonts', + missingFontsDescription: 'These fonts are unavailable for this preview. Text and layout may differ from the original document.', + missingFontsCount: 'Fonts: {count}', + closeDetails: 'Close font details', + unavailable: 'Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.', + invalid: 'This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', + tooLarge: 'The Office file or converted PDF exceeds the preview size limit. Reduce the file size or adjust the preview configuration.', + failed: 'Office conversion did not produce a usable PDF. Check the file and try again.', + timeout: 'Office conversion timed out. Try again.', + busy: 'Office preview is busy. Try again shortly.', + changed: 'The file changed while being read. Reopen the preview.', +} satisfies Record diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css index aa166f58b4..29eeb810b2 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css @@ -5,6 +5,7 @@ width: 100%; min-height: 0; overflow-x: hidden; + background: var(--dsw-alias-bg-document-preview); font-family: var(--dsw-font, sans-serif); white-space: normal; } @@ -32,10 +33,86 @@ margin-inline: auto; } -.canvas[hidden] { +.surface { + position: relative; + max-width: 100%; + margin-inline: auto; + box-shadow: var(--dsw-elevation-prominent); +} + +.surface[hidden] { display: none; } +.text { + position: absolute; + inset: 0; + overflow: clip; +} + +/* TextLayerBuilder selection styles follow pdfjs-dist/web/pdf_viewer.css. */ +.text :global(.textLayer) { + position: absolute; + inset: 0; + text-align: initial; + overflow: clip; + line-height: 1; + letter-spacing: normal; + word-spacing: normal; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +.text :is(span, br) { + position: absolute; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0 0; + user-select: text; +} + +.text :global(.textLayer) > :not(:global(.markedContent)), +.text :global(.markedContent) span:not(:global(.markedContent)) { + z-index: 1; + --font-height: 0; + --scale-x: 1; + --rotate: 0deg; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); +} + +.text :global(.markedContent) { + display: contents; +} + +.text ::selection { + background: var(--dsw-alias-interactive-bg-hover-accent); + color: transparent; +} + +.text br::selection { + background: transparent; +} + +.text :global(.textLayer) :global(.endOfContent) { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + user-select: none; +} + +.text :global(.selecting) :global(.endOfContent) { + top: 0; +} + .status { display: flex; gap: 8px; @@ -46,6 +123,10 @@ line-height: 1.5; } +.body .status { + color: var(--dsw-alias-label-document-preview); +} + /* The document-open wait fills the still-empty body and centres, matching the owner's read spinner position. */ .opening { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts index e256ef110c..460bac419d 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts @@ -1,5 +1,5 @@ /** Canvas rendering with cancellation and page cleanup, shared by the PDF body and real-library smoke. */ -import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist' +import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist' /** The document operations used by one mounted PDF body. */ export type PdfDocument = Pick @@ -21,6 +21,12 @@ export interface PdfPageSize { readonly height: number } +/** Optional DOM text rendering that shares the canvas page's cleanup barrier. */ +export type RenderPdfText = (page: PDFPageProxy, viewport: PageViewport) => { + readonly promise: Promise + cancel(): void +} + /** * Render one page into an exclusively owned canvas. Cancellation cannot write * dimensions after a delayed getPage; active render tasks are cancelled and @@ -30,6 +36,7 @@ export interface PdfPageSize { * @param canvas - canvas owned by this render only. * @param signal - render lifetime. * @param pixelRatio - display pixel ratio. + * @param renderText - optional selectable text layer sharing this page and viewport. * @returns the page's CSS dimensions after rendering completes. */ export async function renderPdfPage( @@ -38,6 +45,7 @@ export async function renderPdfPage( canvas: HTMLCanvasElement, signal: AbortSignal, pixelRatio: number, + renderText?: RenderPdfText, ): Promise { signal.throwIfAborted() const page: PDFPageProxy = await document.getPage(pageNumber) @@ -55,13 +63,25 @@ export async function renderPdfPage( viewport, transform: ratio === 1 ? undefined : [ratio, 0, 0, ratio, 0, 0], }) - const cancel = (): void => { task.cancel() } + let text: ReturnType | undefined + let cancelled = false + const cancel = (): void => { + if (cancelled) return + cancelled = true + task.cancel() + text?.cancel() + } signal.addEventListener('abort', cancel, { once: true }) try { + text = renderText?.(page, viewport) if (signal.aborted) cancel() - await task.promise + await Promise.all([task.promise, text?.promise]) signal.throwIfAborted() return { width: viewport.width, height: viewport.height } + } catch (error) { + cancel() + await Promise.allSettled([task.promise, text?.promise]) + throw error } finally { signal.removeEventListener('abort', cancel) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx index eb409b0a64..0747c730ec 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx @@ -8,6 +8,7 @@ import type { PdfStore, PdfView } from './store.ts' import { renderPdfPage, type PdfDocument } from './document.ts' import { openPdf } from './runtime.ts' import { PdfWorkerFailure } from './errors.ts' +import { pdfTextRenderer } from './text.ts' import type {} from './locales.ts' import css from './PdfBody.module.css' @@ -94,6 +95,8 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig } & PropsLocale<'sidebarPdf'>): ReactNode { const host = useRef(null) const canvas = useRef(null) + const text = useRef(null) + const [width, setWidth] = useState() const [requested, setRequested] = useState(initiallyRequested) const [state, setState] = useState<'loading' | 'ready'>('loading') const [failure, setFailure] = useState<{ readonly error: unknown }>() @@ -125,11 +128,16 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig const renderSignal = AbortSignal.any([lifetime.signal, signal]) setState('loading') setFailure(undefined) - void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio).then( - () => { if (!renderSignal.aborted) setState('ready') }, + const createText = pdfTextRenderer(text.current as HTMLDivElement) + let textTask: ReturnType | undefined + void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio, (pdfPage, viewport) => { + textTask = createText(pdfPage, viewport) + return textTask + }).then( + (size) => { if (!renderSignal.aborted) { setWidth(size.width); setState('ready') } }, (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }, ) - return () => { lifetime.abort() } + return () => { lifetime.abort(); textTask?.cancel() } }, [document, page, requested, signal, attempt]) return
{failure === undefined && state !== 'ready' && (requested @@ -139,8 +147,10 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig {failureText(failure.error, t)}
} -