feat: improve file attachments and add Office artifact previews (#8009)

* feat(frontend): improve file attachment recovery and artifact presentation

* feat(extensions): add Office artifact previews with search and navigation

* fix: preserve public viewers and HTTP Office previews

* fix: address file preview and upload review findings

* fix: use typed upload errors and linear bookmark allocation

* fix(office-preview): isolate parsing and bound preview resources

* fix(ci): install Office preview fixture dependencies for E2E

* fix(ci): provision matching Playwright browsers before E2E tests

* fix(ci): isolate Python libraries and include Office documentation

* test(frontend): wait for download status to finish rendering
This commit is contained in:
Eilen Shin 2026-11-12 20:30:17 +08:00 committed by GitHub
parent b4410f64d4
commit 81de0761ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 18952 additions and 258 deletions

View File

@ -14,6 +14,7 @@ on:
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
@ -28,6 +29,7 @@ on:
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml'
concurrency:
@ -41,6 +43,15 @@ jobs:
e2e-tests:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
# Keep the image version aligned with frontend/pnpm-lock.yaml. Browsers and
# OS libraries are installed together, without per-shard CDN downloads.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
# The host tool cache can contain Python built for a newer OpenSSL than
# this image. uv's standalone Python carries compatible runtime libraries.
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25
strategy:
fail-fast: false
@ -51,21 +62,20 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
# The bookmark browser test starts the real Python action/router fixture.
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
# Keep aligned with backend/Dockerfile and other backend workflows.
version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (bookmark plugin gateway)
working-directory: backend
run: uv sync --locked
run: |
uv sync --locked
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js
uses: actions/setup-node@v4
@ -82,9 +92,20 @@ jobs:
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
# Playwright collects the resource suite before selecting a shard. Its
# synthetic Office fixtures use JSZip from the extension's locked install.
- name: Install Office preview test dependencies
working-directory: examples/deerflow-extension-office-preview
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Verify Playwright Chromium
working-directory: frontend
run: npx playwright install chromium --with-deps
id: chromium
run: >-
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Run E2E tests
working-directory: frontend
@ -100,7 +121,7 @@ jobs:
- name: Run auth recovery E2E tests
# Keep auth coverage unsharded and run it even if the first shard fails.
if: ${{ !cancelled() && matrix.shard == 1 }}
if: ${{ !cancelled() && matrix.shard == 1 && steps.chromium.outcome == 'success' }}
working-directory: frontend
run: >-
pnpm exec playwright test -c playwright.auth.config.ts

View File

@ -71,21 +71,27 @@ jobs:
name: Layer 2 — full-stack render (no API key)
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
# Match the locked Playwright runtime so the image supplies its browsers.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (replay gateway)
working-directory: backend
run: uv sync --group dev
run: |
uv sync --group dev
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@ -97,9 +103,13 @@ jobs:
- name: Install frontend dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
- name: Verify Playwright Chromium
working-directory: frontend
run: npx playwright install chromium --with-deps
run: >-
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Full-stack replay render (DOM assertions are the gate)
working-directory: frontend
run: pnpm exec playwright test -c playwright.real-backend.config.ts

View File

@ -35,6 +35,9 @@ Humans start at [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md) and
Nginx serves the frontend, proxies `/api/*` to the Gateway and rewrites
`/api/langgraph/*` to the Gateway's native routes.
Before changing ingress, port bindings or provisioner mounts, read
[service topology details](docs/agent-service-topology.md).
## Repository map
| Path | Content |
@ -109,3 +112,11 @@ Values, defaults, field lists, function inventories, test names, issue numbers
and history stay in the code, the tests and git. Write full sentences in the
positive form and give the reason. `make check-agent-guidance` enforces the size
budget.
### Office preview example
`examples/deerflow-extension-office-preview/` packages Office readers through the
optional `artifactPreviews` browser contract. Keep parser dependencies and resources
in the extension; rebuild its manifest and static module graph after UI changes.
Test packaged assets with the Office E2E fixtures; rendering must preserve original
downloads and state its search limits.

View File

@ -383,6 +383,15 @@ Every pull request triggers the following CI workflows:
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
The browser E2E and full-stack replay jobs use the official Playwright image,
which includes Chromium and its system libraries. When upgrading the locked
Playwright version, update the image tags in both browser workflows to match.
Each job launches Chromium before running tests; auth recovery tests run only
after that browser readiness check succeeds.
These containers use uv-managed Python instead of the host's Python tool cache,
so the interpreter's OpenSSL libraries match its runtime. Backend dependency
setup also verifies SSL and SQLite imports before launching fixture gateways.
### Test and diagnostics notes
Backend `make test` runs four duration-balanced shards concurrently and reports

View File

@ -113,6 +113,7 @@ Each guide is the full reference for its topic. Open the one you need.
| Projects, instructions, document shelf | [Projects](docs/guide/projects.md) |
| Scheduled tasks | [Scheduled Tasks](docs/guide/scheduled-tasks.md) |
| Feishu, Slack, Telegram, Discord, DingTalk and other IM channels | [IM Channels](docs/guide/im-channels.md) |
| File attachments, downloads and Office previews | [File Previews](docs/guide/file-previews.md) |
| Token usage dashboard, workspace, chat archive | [Workspace UI](docs/guide/workspace.md) |
| Terminal workbench | [Terminal Workbench (TUI)](docs/guide/tui.md) |
| Personal access tokens, stream actions, embedded Python client | [API Access and Python Client](docs/guide/api-and-client.md) |

View File

@ -1,4 +1,4 @@
"""Browser CI installs its dependencies before executing browser tests."""
"""Browser CI supplies matching browsers and verifies them before tests."""
from pathlib import Path
@ -7,15 +7,46 @@ import yaml
@pytest.mark.parametrize(("workflow", "job"), [("e2e-tests.yml", "e2e-tests"), ("replay-e2e.yml", "fullstack-replay-render")])
def test_browser_ci_installs_chromium_before_running_tests(workflow, job):
def test_browser_ci_image_matches_locked_playwright(workflow, job):
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / ".github/workflows" / workflow).read_text(encoding="utf-8"))
lock = yaml.safe_load((root / "frontend/pnpm-lock.yaml").read_text(encoding="utf-8"))
version = lock["importers"]["."]["devDependencies"]["@playwright/test"]["version"]
image = config["jobs"][job].get("container", {}).get("image", "")
assert image.startswith(f"mcr.microsoft.com/playwright:v{version}-"), "The preinstalled browsers must match the locked Playwright runtime"
@pytest.mark.parametrize(("workflow", "job"), [("e2e-tests.yml", "e2e-tests"), ("replay-e2e.yml", "fullstack-replay-render")])
def test_browser_ci_verifies_chromium_before_running_tests(workflow, job):
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / ".github/workflows" / workflow).read_text(encoding="utf-8"))
browser_job = config["jobs"][job]
# Runner images are maintained by CI owners; do not freeze a temporary
# OS compatibility workaround in an unrelated backend unit test.
steps = browser_job["steps"]
installs = [index for index, step in enumerate(steps) if "playwright install chromium --with-deps" in step.get("run", "")]
probes = [index for index, step in enumerate(steps) if "await chromium.launch()" in step.get("run", "") and "await browser.close()" in step.get("run", "")]
tests = [index for index, step in enumerate(steps) if "pnpm exec playwright test" in step.get("run", "")]
assert installs, "Browser dependencies must be installed"
assert probes, "Launch the image's Chromium before executing browser tests"
assert tests, "The browser job must execute tests"
assert max(installs) < min(tests), "Install Chromium before running browser tests"
assert max(probes) < min(tests), "Verify Chromium before running browser tests"
def test_auth_recovery_requires_a_working_browser():
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / ".github/workflows/e2e-tests.yml").read_text(encoding="utf-8"))
steps = config["jobs"]["e2e-tests"]["steps"]
probe = next(step for step in steps if "await chromium.launch()" in step.get("run", ""))
auth = next(step for step in steps if "playwright.auth.config.ts" in step.get("run", ""))
assert f"steps.{probe['id']}.outcome == 'success'" in auth["if"]
@pytest.mark.parametrize(("workflow", "job"), [("e2e-tests.yml", "e2e-tests"), ("replay-e2e.yml", "fullstack-replay-render")])
def test_browser_ci_uses_container_compatible_python(workflow, job):
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / ".github/workflows" / workflow).read_text(encoding="utf-8"))
browser_job = config["jobs"][job]
assert browser_job.get("env", {}).get("UV_PYTHON_PREFERENCE") == "only-managed"
steps = browser_job["steps"]
assert not any(step.get("uses", "").startswith("actions/setup-python@") for step in steps)
install = next(index for index, step in enumerate(steps) if "uv python install 3.12" in step.get("run", ""))
sync = next(index for index, step in enumerate(steps) if "uv sync" in step.get("run", ""))
assert install < sync
assert any("import ssl, sqlite3" in step.get("run", "") for step in steps)

View File

@ -184,7 +184,9 @@ def test_bundled_documentation_snapshot_is_current():
root = Path(__file__).resolve().parents[2]
script = runpy.run_path(str(root / "scripts/sync_extension_documentation.py"))
assert script["DESTINATION"].read_text(encoding="utf-8") == script["render"]()
assert len(json.loads(script["render"]())) == 15
pages = json.loads(script["render"]())
assert len(pages) == 16
assert pages["deerflow-extension-office-preview"]["id"] == "office-preview"
@pytest.mark.parametrize("collision", [False, True])

View File

@ -0,0 +1,18 @@
# Service topology details
Keep the dedicated `/api/threads/{thread_id}/browser/stream` WebSocket regex before
the generic threads regex in Docker, local and Helm nginx configs. Upgrade headers
and the 600s read timeout are pinned by `backend/tests/test_nginx_browser_websocket.py`.
Helm uses nginx's default connect/send timeouts for the in-cluster Gateway.
Both Compose files publish nginx at `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
(README's **loopback default**); bare `"${PORT}:2026"` binds `0.0.0.0`. Root `PORT`
controls Docker ingress only; local Next.js stays on `3000` so `.env` cannot
redirect `make dev`'s port wait. Nginx's IPv4+IPv6 `default_server` and Gateway's
`0.0.0.0:8001` bind are container-internal; only published nginx is external.
Require explicit bind addresses for new published ports;
`backend/tests/test_compose_default_bind_host.py` pins every service in both files.
Provisioner PVC mode declares `USERDATA_PVC_NAME` once as `user-data`; extra and
Lark broker credential mounts reuse it with their existing subPaths and read-only
flags. Keep credential mounts broker-only and keep distinct volumes in hostPath mode.

View File

@ -174,6 +174,29 @@ strict validation.
## Packaged resources (`assets-v1`)
### Artifact previews
Browser modules can declare `artifactPreviews: [{ id, extensions, mount }]` to
render otherwise unsupported binary files in the artifact panel and separate reader.
`extensions` contains 1–32 exact lowercase extensions without dots (for example
`["xlsx", "docx", "pptx"]`); there may be at most 16 uniquely named renderers per
module. Built-in text and browser media readers take precedence. The first enabled
matching contribution for the current viewer is selected.
`mount(root, { filename, locale, signal, readFile, onError })` runs synchronously and
returns `{ dispose() }`. The host places the root inside a shadow tree and remounts
on viewer, file, conversation, locale or contribution changes. `readFile()` uses the
current conversation's authenticated artifact route and enforces a 25 MiB streamed
limit. Observe the abort signal during asynchronous work; call `onError()` for a
localized download fallback, and release all resources in `dispose()`. Shadow DOM
isolates styling, not plugin privileges. Third-party parsers must ship in the trusted
package and apply their own expansion/render limits.
Optional `getReadingState()` and `saveReadingState(state)` callbacks become available
after `readFile()` and retain bounded JSON in memory for unchanged content in the
current viewer session. They are scoped to viewer, thread, file, renderer and content
hash. Do not store document contents or credentials in reading preferences.
Register a package-owned directory alongside the Python implementation:
```python

View File

@ -0,0 +1,35 @@
# File attachments and previews
## Attachments and downloads
Attachments show individual ready, uploading, uploaded, failed and cancelled states.
Failed uploads retain per-file error details; session-state messages remain localized.
Retry a failed file or remove it before sending again; successful uploads and the
message draft are retained. File cards share format icons and preserve extensions
in long filenames. Sent attachments open in the right-hand artifact panel.
Single-file downloads show preparation and retry feedback while preventing duplicate
requests. Unsupported formats offer the original file for download.
Public Showcase files keep their standalone viewer and download controls available
without requiring sign-in.
Deployments can add binary readers through the optional `artifactPreviews` browser
contribution; see the [plugin contract](../full-stack-plugins.md#artifact-previews).
## Office artifact preview extension
The optional [Office preview package](../../examples/deerflow-extension-office-preview/README.md)
adds read-only XLSX, DOCX and PPTX readers with search, worksheet/page navigation,
Word outlines, PowerPoint thumbnails and per-file reading preferences. It requires
the artifact-preview host contribution and operator installation; it is not enabled
by a catalog entry alone. Legacy binary Office files retain the download fallback.
Previews also work over LAN HTTP. Reading preferences use the file's ETag when
available, with an optional content hash on secure origins; without either, files
still open but reading preferences are not restored.
Office preview validates ZIP metadata before loading, prepares files in a cancellable
Worker with a deadline, and limits XML/layout and image sizes before DOM rendering.
Word XML/model preparation and PPT conversion run in that Worker; the UI receives
bounded models with images and document relationships preserved.
Files exceeding preview budgets retain the original-file download fallback.
See the [Office package guide](../../examples/deerflow-extension-office-preview/README.md)
for installation, supported formats, resource limits and validation commands.

View File

@ -9,7 +9,8 @@ independent Python package using the public `deerflow-extension-api` contract.
| [Conversation bookmarks](deerflow-extension-bookmarks/README.md) | A browser page, conversation action, backend actions, packaged resources, and a model tool. Start here for full-stack plugins. |
| [Agent teams](deerflow-extension-agent-teams/README.md) | Full Agent orchestration. Use this after the basic contribution and lifecycle patterns are familiar. |
Additional examples cover [text-list classification](deerflow-extension-jev-classify/README.md),
Additional examples cover [Office file previews](deerflow-extension-office-preview/README.md),
[text-list classification](deerflow-extension-jev-classify/README.md),
[context pruning](deerflow-extension-jev-context/README.md),
[advisory screening of fetched content](deerflow-extension-jev-screening/README.md), and
[sectioned long-form report writing](deerflow-extension-report-sections/README.md).

View File

@ -0,0 +1,2 @@
# Keep generated parser bundles byte-for-byte, including embedded template strings.
deerflow_extension_office_preview/static/dist/** -whitespace linguist-generated=true

View File

@ -0,0 +1,4 @@
node_modules/
!deerflow_extension_office_preview/static/dist/
!deerflow_extension_office_preview/static/dist/**
*.egg-info/

View File

@ -0,0 +1,147 @@
# Office file preview
An installable browser contribution for `.xlsx`, `.docx` and `.pptx` files in
DeerFlow's artifact panel and standalone reader. Files stay within the current
authenticated artifact API; no public document viewer or conversion service is used.
Uses the host's optional `artifactPreviews` browser contract. The package is
activated only after operator installation and a Gateway restart.
## Build and install
The package includes its built browser modules. After editing `ui/`, rebuild:
```sh
cd examples/deerflow-extension-office-preview
npm ci --ignore-scripts
npm run build
node notices.mjs
```
From the repository root, check packaging, then install through the extension manager:
```sh
uv run --project backend --no-sync python scripts/check_extension.py examples/deerflow-extension-office-preview
cd backend
uv run deerflow extensions install ../examples/deerflow-extension-office-preview --yes
```
Restart Gateway and refresh the browser. A matching local extension-api and harness
must already be installed in the Gateway environment. The resulting operator-owned
configuration is equivalent to:
```yaml
plugins:
- use: deerflow_extension_office_preview:install
enabled: true
config:
enabled: true
```
Use `deerflow extensions upgrade SOURCE` after later edits: installs are snapshots.
To disable this contribution, set `config.enabled: false` and restart Gateway.
Other formats retain their built-in preview or download fallback.
## Preview behavior and limits
- Excel: visible worksheet tabs, row/column headers, basic fonts, fills, alignment,
merged cells, an address jump, a focused cell/formula bar and copying cell values.
Arrow keys skip hidden rows/columns and merged ranges; sheet tabs support arrow
keys and Home/End. Selection and scroll restore independently per sheet. Jumping
inside a visible merged range selects its master cell. At most 500 rows and 50 columns per sheet. Formula results use saved
workbook caches; formulas are never recalculated. Charts and complex formatting
are not reproduced. Drawing parts are omitted only from the temporary preview
copy so workbooks containing charts can still display and search their cells;
original downloads retain the charts, tables and other workbook content.
- Word: paginated document layout, page navigation and zoom. The page counter follows
manual scrolling and navigation buttons disable at the first/last page. Advanced Word layout,
missing fonts and embedded objects can differ from Office.
- PowerPoint: slide navigation with arrows, Page Up/Down and Home/End when the preview
canvas or its navigation controls are focused, plus zoom and fit width; browser conversion supports
common text, shapes, images and tables. Paragraph font defaults are materialized
in a temporary preview copy. Animations, SmartArt and advanced effects can differ
or be absent. Generated slide HTML uses a sandboxed iframe without scripts or
external resources.
- All formats: host reads are capped at 25 MiB, including streaming responses without
a reliable Content-Length. ZIP central/local records, names, member counts, size
declarations and compression ratios are checked **before** JSZip loads an archive.
Split/encrypted/ZIP64 archives and inconsistent metadata use the download fallback.
Limits: 4,000 members, 100 MiB aggregate actual/declared expansion, 16 MiB per member,
compression ratio 200:1, and 2 MiB per XML part. Sequential streamed inflation also
checks actual output, so forged size declarations cannot bypass these limits.
- A dedicated Worker validates/prepares each file with a 15-second deadline. Timeout,
cancellation or disposing the preview terminates the Worker, including synchronous
parser work. ZIP/XML work, ExcelJS, Word model/outline preparation and PPT conversion
all run there. The UI receives bounded models rather than Office archives; only display
data (up to 20 sheets / 100,000 cells in total) reaches the Excel UI. Rows render in
batches and obsolete sheet renders are discarded. A blocked/unavailable Worker
fails safely; there is no main-thread parsing fallback.
- XML rejects DTDs, excessive nesting, cyclic/deep Word style inheritance and
oversized merged ranges. Worker XML budgets are 100,000 elements and 2 MiB text;
Word/PPT are additionally capped at 20,000 rendered XML elements, 2,000 layout units
(paragraphs, rows, breaks and shapes), 50 slides and 32 MiB prepared bytes.
PNG/JPEG images are checked before browser decoding (16 megapixels each, 32 total);
unsupported media uses the download fallback. Embedded objects are omitted and
Word does not load embedded fonts. Word parsing uses docx-preview's public API with
an XML adapter in the Worker; the main thread receives only its plain document model
and validated image bytes. Headers/footers and images retain their package-relative
relationships. Image Blob URLs are deduplicated and revoked on abort/disposal.
PPT receives the same XML adapter through its documented domParserFactory hook.
Large sparse chart indices and conservative image-reference amplification bounds
reject small archives that could otherwise produce huge arrays or repeated data URLs.
Model output is capped at 32 MiB and 100,000 objects before transfer, with 512 KiB
per converted PPT slide. The UI does not reopen ZIPs or parse Office XML.
Word's final DOM construction and browser HTML/layout work remain on the main thread.
These bounded native DOM stages cannot be interrupted by a timer or AbortSignal;
this is not a hard browser memory
quota or complete process isolation. Download complex documents for full fidelity.
- Legacy binary `.xls`, `.doc` and `.ppt` files show the download fallback.
- Fit width remains active as the preview panel resizes; manual zoom exits that mode.
Failed previews offer retry and download. Clipboard failures show a manual-copy hint.
- Find in file offers literal, case-insensitive search, counts, highlighting and
previous/next matches (Enter / Shift+Enter). Ctrl/Cmd+F focuses it; Escape clears it.
Excel searches visible cells across visible sheets within the 500-row/50-column
limit; it searches displayed results rather than formula expressions. Word searches
rendered text, including styled runs. PPT searches slide text. Images, charts and
embedded objects are outside the search scope, which the toolbar explains.
Search counts/navigation/highlights are capped at 2,000 matches to avoid turning
repetitive text into an unbounded number of DOM nodes or callbacks.
- Word's collapsible outline follows actual OOXML heading levels. Preview bookmarks
preserve existing names and numeric IDs; a shared allocator indexes reserved IDs
once and probes each candidate at most once, without Web Crypto or name parsing.
PowerPoint's
collapsible thumbnails render only near the navigation viewport and retain the
same sandbox/CSP restrictions as the main slide.
- Switching files restores worksheet/page, zoom, scroll and navigation visibility
for unchanged content in the current viewer session. State is in memory, bounded
by the host, and isolated by account, conversation, renderer and file content hash.
Browser code is trusted deployment code, not an untrusted plugin sandbox. The
shadow root isolates presentation styles. Abort/dispose terminates preparation and
fences obsolete mounts; it cannot preempt the bounded DOM-only conversion stages.
The Worker is bundled without additional module fetches to work with Gateway's
`Content-Security-Policy: sandbox` asset responses, including authenticated deployments.
## Validation
```sh
python -m unittest discover -s examples/deerflow-extension-office-preview/tests
node --test examples/deerflow-extension-office-preview/tests/*.test.mjs
cd frontend
pnpm exec rstest run binary-preview plugin-artifact-preview registry.test catalog.test
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3000 PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test office-preview-interactions office-preview-navigation --workers=1
PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test word-outline --workers=1
PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test office-preview-resources --workers=1
```
The browser regression serves the actual packaged module graph and synthetic Office
files through mocked Gateway APIs. It does not require an LLM. Run browser tests
with a production frontend on the configured port.
The resource suite uses its own HTTP asset server, needs the extension's locked npm
dependencies (`npm ci --ignore-scripts`) and Chromium, and requires Linux `ps` for
memory measurements. It records sampled process-tree RSS (including Worker/native
allocations), UI heap, long tasks and event-loop gaps in test attachments. Sampled
peaks can miss short-lived allocations and summed RSS includes shared pages; these
are regression measurements, not a proof of an absolute memory bound. Set
`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to use an installed Chromium executable.
See [third-party notices](THIRD_PARTY_NOTICES.md) for renderer attribution.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
import { build } from "esbuild";
import { readdir, rm, writeFile } from "node:fs/promises";
const root = "deerflow_extension_office_preview";
await rm(new URL(`./${root}/static/dist/`, import.meta.url), {
recursive: true,
force: true,
});
await build({
entryPoints: { index: "ui/index.js" },
bundle: true,
splitting: true,
format: "esm",
platform: "browser",
target: "es2022",
outdir: `${root}/static/dist`,
entryNames: "[dir]/[name]",
chunkNames: "chunks/[name]-[hash]",
outExtension: { ".js": ".mjs" },
minify: true,
legalComments: "eof",
});
// Gateway assets carry CSP: sandbox. Keep this worker self-contained: a worker
// with an opaque origin cannot fetch additional authenticated module chunks.
await build({
entryPoints: ["ui/office-worker.js"],
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
outfile: `${root}/static/dist/chunks/office-worker.mjs`,
minify: true,
legalComments: "eof",
});
const files = (await readdir(`${root}/static/dist`, { recursive: true }))
.filter((p) => /\.mjs$/.test(p))
.map((p) => `static/dist/${p.replaceAll("\\", "/")}`)
.sort();
await writeFile(
`${root}/ui_manifest.json`,
JSON.stringify(
{ schema_version: 1, entry: "static/dist/index.mjs", files },
null,
2,
) + "\n",
);

View File

@ -0,0 +1,24 @@
"""Browser-only previews; all file reads retain the host artifact authorization."""
from pathlib import Path
from deerflow_extension_api import extension
from deerflow_extension_api.plugins import BrowserAssets, PluginContribution
@extension(api="0.2.3", name="office-preview")
def install(registry, config):
enabled = config.get("enabled", True)
if type(enabled) is not bool:
raise ValueError("enabled must be a boolean")
accepted = registry.plugin(
PluginContribution(
namespace="office-preview",
title="Office preview / Office 文件预览",
description="Read Excel, Word and PowerPoint files in the artifact panel.",
enabled=enabled,
frontend=BrowserAssets(module="office-preview", root=Path(__file__).parent),
)
)
if not accepted:
raise RuntimeError("This host does not support browser plugin contributions")

View File

@ -0,0 +1,51 @@
{
"schema_version": 1,
"id": "office-preview",
"locales": {
"en-US": {
"title": "Office file preview",
"description": "Preview Excel, Word and PowerPoint files in the side panel with an installed browser extension.",
"usage": [
{
"title": "Open an Office file",
"description": "Open an XLSX, DOCX or PPTX attachment or generated file in the right-hand reader."
},
{
"title": "Search and navigate",
"description": "Search displayed text and use worksheet tabs, page controls, outlines or thumbnails. Search shows up to 2,000 matches."
},
{
"title": "Download the original",
"description": "Download the original when a format is unsupported or a file exceeds preview limits."
}
],
"requirements": [
"Ask your administrator to install the Office preview extension, restart Gateway and refresh the browser.",
"Read-only XLSX, DOCX and PPTX files are supported, subject to the resource limits in the package guide."
]
},
"zh-CN": {
"title": "Office 文件预览",
"description": "安装浏览器扩展后,在右侧面板预览 Excel、Word 和 PowerPoint 文件。",
"usage": [
{
"title": "打开 Office 文件",
"description": "在右侧阅读面板打开 XLSX、DOCX 或 PPTX 附件或生成的文件。"
},
{
"title": "搜索与导航",
"description": "搜索显示的文字,并使用工作表标签、翻页、大纲或缩略图导航;最多显示 2,000 处匹配。"
},
{
"title": "下载原始文件",
"description": "文件格式不受支持或超过预览限制时,可下载原始文件。"
}
],
"requirements": [
"请管理员安装 Office 预览扩展、重启 Gateway,并刷新浏览器。",
"支持只读预览 XLSX、DOCX 和 PPTX,资源限制详见扩展使用说明。"
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-office-preview#readme"
}

View File

@ -0,0 +1 @@
var g=Object.create;var f=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var j=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var m=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(b,c)=>(typeof require<"u"?require:b)[c]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var n=(a,b)=>()=>(b||a((b={exports:{}}).exports,b),b.exports);var l=(a,b,c,e)=>{if(b&&typeof b=="object"||typeof b=="function")for(let d of i(b))!k.call(a,d)&&d!==c&&f(a,d,{get:()=>b[d],enumerable:!(e=h(b,d))||e.enumerable});return a};var o=(a,b,c)=>(c=a!=null?g(j(a)):{},l(b||!a||!a.__esModule?f(c,"default",{value:a,enumerable:!0}):c,a));export{m as a,n as b,o as c};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import"./chunks/chunk-OVILVMKS.mjs";var t={apiVersion:1,module:"office-preview",artifactPreviews:[{id:"office",extensions:["xlsx","docx","pptx"],mount(o,r){let e=new AbortController,i=()=>e.abort();r.signal.addEventListener("abort",i,{once:!0}),r.signal.aborted&&i();let a=()=>{};return import("./chunks/office-3UIRH4NA.mjs").then(async({mountOffice:n})=>{e.signal.aborted||(a=await n(o,{...r,signal:e.signal}),e.signal.aborted&&a())}).catch(()=>{e.signal.aborted||r.onError()}),{dispose(){e.abort(),r.signal.removeEventListener("abort",i),a(),o.replaceChildren()}}}}]};export{t as default};

View File

@ -0,0 +1,11 @@
{
"schema_version": 1,
"entry": "static/dist/index.mjs",
"files": [
"static/dist/chunks/chunk-OVILVMKS.mjs",
"static/dist/chunks/docx-preview-VUQTZ67Q.mjs",
"static/dist/chunks/office-3UIRH4NA.mjs",
"static/dist/chunks/office-worker.mjs",
"static/dist/index.mjs"
]
}

View File

@ -0,0 +1,29 @@
// Preserve upstream license texts for the locked production dependency tree,
// including libraries already embedded in ExcelJS's browser distribution.
import { readdir, readFile, writeFile } from "node:fs/promises";
const lock = JSON.parse(await readFile("package-lock.json", "utf8"));
// Indent quoted upstream text so README underlines cannot resemble merge markers.
// Only insignificant trailing whitespace is removed from license notices.
const quoteNotice = body => body.trim().split(/\r?\n/).map(line => line.trimEnd() ? ` ${line.trimEnd()}` : "").join("\n");
let output = `# Third-party notices\n\nOffice preview is independently implemented for DeerFlow. Asset-System was\nreviewed as a UX reference; its source code and assets are not included.\n\nThe browser bundle uses ExcelJS, docx-preview, JSZip and @jvmr/pptx-to-html.\nThe following notices cover the locked production dependency tree, including\ntransitive packages embedded in prebuilt browser distributions. JSZip is used\nunder its MIT license option. Node-only dependency notices are included\nconservatively; their inclusion does not imply that their code ships to browsers.\n\nGenerated by \`node notices.mjs\` from \`package-lock.json\` and installed packages.\n\n`;
for (const [directory, entry] of Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b))) {
if (!directory || entry.dev) continue;
const pkg = JSON.parse(await readFile(`${directory}/package.json`, "utf8"));
const files = (await readdir(directory)).filter(name => /^(licen[sc]e|copying|notice)([.-]|$)/i.test(name));
output += `## ${pkg.name} ${pkg.version}\n\nLicense: ${pkg.license ?? entry.license ?? "see upstream notice"}.\n\n`;
for (const name of files.sort()) {
let body;
try { body = await readFile(`${directory}/${name}`, "utf8"); } catch { continue; }
output += `### ${name}\n\n${quoteNotice(body)}\n\n`;
}
if (!files.length) {
// Several older npm packages put their copyright and permission grant
// in README instead of shipping a separate LICENSE file.
const readme = (await readdir(directory)).find(name => /^readme([.-]|$)/i.test(name));
if (!readme) throw new Error(`Missing license notice: ${directory}`);
const body = await readFile(`${directory}/${readme}`, "utf8");
output += `### ${readme} (upstream notice)\n\n${quoteNotice(body)}\n\n`;
}
}
await writeFile("THIRD_PARTY_NOTICES.md", output.trimEnd() + "\n", "utf8");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
{
"name": "deerflow-office-preview-ui",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@jvmr/pptx-to-html": "1.1.2",
"@xmldom/xmldom": "0.9.12",
"docx-preview": "0.3.7",
"exceljs": "4.4.0",
"jszip": "3.10.1",
"saxes": "6.0.0"
},
"devDependencies": {
"esbuild": "0.25.12"
}
}

View File

@ -0,0 +1,21 @@
[project]
name = "deerflow-extension-office-preview"
version = "0.1.0"
description = "Local Office file previews for DeerFlow artifact panels"
requires-python = ">=3.12"
license = "MIT"
license-files = ["THIRD_PARTY_NOTICES.md"]
dependencies = ["deerflow-extension-api>=0.2.3,<0.3"]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.15"]
[project.entry-points."deerflow.extensions"]
office_preview = "deerflow_extension_office_preview:install"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow_extension_office_preview"]

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WorkerXmlParser } from "../ui/worker-xml.js";
import { checkModelBudget, transferBuffers } from "../ui/document-model.js";
import { wordView } from "../ui/word-view.js";
test("worker XML preserves namespace, descendant and escaped-prefix selectors", () => {
const doc = new WorkerXmlParser().parseFromString(
'<p:root xmlns:p="urn:p" xmlns:a="urn:a"><p:spPr/><a:lstStyle><a:defRPr><a:solidFill/></a:defRPr></a:lstStyle></p:root>',
"application/xml",
);
assert.equal(doc.querySelector("p\\:spPr, spPr").namespaceURI, "urn:p");
assert.equal(doc.querySelector("*|lstStyle *|defRPr").localName, "defRPr");
assert.equal(doc.querySelectorAll("*|solidFill").length, 1);
assert.equal(doc.firstElementChild, doc.documentElement);
assert.throws(() => doc.querySelector("div > span"), /unsupported/i);
});
test("parser output budgets reject amplified arrays and strings before transfer", () => {
assert.throws(() => checkModelBudget({ nodes: new Array(100001) }), /limit/i);
assert.throws(
() => checkModelBudget({ html: "x".repeat(17 * 1024 * 1024) }),
/limit/i,
);
const bytes = new Uint8Array(10);
const model = { bytes, again: bytes.subarray(1) };
model.self = model;
assert.deepEqual(transferBuffers(model), [bytes.buffer]);
assert.doesNotThrow(() => checkModelBudget(model));
});
test("Word resource facade resolves headers and images, deduplicates URLs and revokes on disposal", async () => {
const image = new Uint8Array([0x89, 0x50]);
const rel = { id: "image", type: "image", target: "media/image.png" };
const body = { path: "word/document.xml", rels: [rel] };
const header = {
path: "word/header.xml",
rels: [rel],
rootElement: { type: "header" },
};
body.rels.push(
{ id: "header", target: "header.xml" },
{
id: "external",
target: "https://invalid.test/image.png",
targetMode: "External",
},
);
const view = wordView({
word: {
documentPart: body,
partsMap: { "word/header.xml": header },
rels: [],
},
images: { "word/media/image.png": image },
});
assert.equal(view.document.findPartByRelId("header", body), header);
const first = await view.document.loadDocumentImage("image");
assert.ok(first.startsWith("blob:"));
assert.equal(await view.document.loadDocumentImage("image", header), first);
assert.equal(await view.document.loadDocumentImage("external"), null);
assert.equal((await fetch(first)).status, 200);
view.dispose();
view.dispose();
assert.equal(await view.document.loadDocumentImage("image"), null);
await assert.rejects(fetch(first));
});

View File

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createUniqueIdAllocator } from "../ui/identifiers.js";
test("allocates distinct exact names without parsing user-supplied identifiers", () => {
const allocate = createUniqueIdAllocator(
["office-0", "office-2", "office-1-0", "office-01", "unrelated"],
"office-",
);
assert.equal(allocate(), "office-1");
assert.equal(allocate(), "office-3");
assert.equal(allocate(), "office-4");
});
test("consumes a large one-shot source once and skips all occupied identifiers", () => {
const count = 100000;
let reads = 0;
function* names() {
for (let index = 0; index < count; index++) {
reads++;
// Include the crafted-file shape from the review as well as exact collisions.
yield `office-${index}-0`;
yield `office-${index}`;
}
}
const allocate = createUniqueIdAllocator(names(), "office-");
assert.equal(allocate(), `office-${count}`);
assert.equal(allocate(), `office-${count + 1}`);
assert.equal(reads, count);
});
test("supports numeric OOXML ids and independent document lifetimes", () => {
const first = createUniqueIdAllocator(["100000", "100002"], "", 100000);
const second = createUniqueIdAllocator([], "", 100000);
assert.equal(first(), "100001");
assert.equal(first(), "100003");
assert.equal(second(), "100000");
});

View File

@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import JSZip from "jszip";
import { prepareDocument } from "../ui/prepare-document.js";
import { LIMITS, preflightZip } from "../ui/resource-limits.js";
const fixtures = new URL(
"../../../frontend/tests/e2e/fixtures/office-preview/",
import.meta.url,
);
async function source(name) {
const bytes = await readFile(new URL(name, fixtures));
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length);
}
for (const [name, kind] of [
["navigation.xlsx", "xlsx"],
["navigation.docx", "docx"],
["project-review.pptx", "pptx"],
["chart-only.xlsx", "xlsx"],
]) {
test(`preserves preview data for ${name}`, async () => {
const bytes = await source(name),
original = new Uint8Array(bytes).slice();
const result = await prepareDocument(bytes, kind);
assert.deepEqual(
new Uint8Array(bytes),
original,
"original download bytes stay unchanged",
);
if (kind === "xlsx") {
assert.ok(result.sheets.length > 0);
assert.ok(result.sheets[0].rows[0].cells.length > 0);
} else assert.ok(result.word || result.slides);
});
}
for (const [kind, name, entry] of [
["xlsx", "navigation.xlsx", "xl/worksheets/sheet1.xml"],
["docx", "navigation.docx", "word/document.xml"],
["pptx", "project-review.pptx", "ppt/slides/slide1.xml"],
]) {
test(`rejects adversarial ${kind} within the old byte/member limits`, async () => {
const zip = await JSZip.loadAsync(await source(name));
zip.file(
entry,
"<root>" + "<n/>".repeat(LIMITS.xmlElements + 1) + "</root>",
);
const bytes = await zip.generateAsync({
type: "arraybuffer",
compression: "STORE",
});
assert.ok(bytes.byteLength < LIMITS.inputBytes);
const records = preflightZip(bytes);
assert.ok(records.length < 4000);
assert.ok(
records.reduce((n, record) => n + record.uncompressed, 0) <
100 * 1024 * 1024,
);
await assert.rejects(prepareDocument(bytes, kind), /complex|limit/i);
});
}
test("forged metadata is rejected before JSZip loads any archive", async () => {
const bytes = await source("navigation.docx");
const record = preflightZip(bytes).find(
(record) => !record.name.endsWith("/"),
);
new DataView(bytes).setUint32(
record.directoryOffset + 24,
101 * 1024 * 1024,
true,
);
const load = JSZip.loadAsync;
JSZip.loadAsync = () =>
assert.fail("archive parser must not run before metadata validation");
try {
await assert.rejects(prepareDocument(bytes, "docx"), /large|limit/i);
} finally {
JSZip.loadAsync = load;
}
});
test("actual inflation is bounded even when local and central sizes both lie", async () => {
const zip = new JSZip().file(
"word/document.xml",
"<r>" + "x".repeat(500000) + "</r>",
{ createFolders: false },
);
const bytes = await zip.generateAsync({
type: "arraybuffer",
compression: "DEFLATE",
});
const view = new DataView(bytes);
// Read offsets before forging a lower declared expansion. Initial ratio is intentionally excessive.
const raw = new Uint8Array(bytes);
let central = -1;
for (let p = 0; p < raw.length - 4; p++)
if (view.getUint32(p, true) === 0x02014b50 && raw[p + 46] === 119) {
central = p;
break;
}
const local = view.getUint32(central + 42, true);
view.setUint32(central + 24, 1000, true);
view.setUint32(local + 22, 1000, true);
await assert.rejects(prepareDocument(bytes, "docx"), /expan|size/i);
});
test("raster dimensions are bounded before DOM decoding", async () => {
const png = new Uint8Array(24),
view = new DataView(png.buffer);
view.setUint32(0, 0x89504e47);
view.setUint32(16, 100000);
view.setUint32(20, 100000);
for (const filename of ["word/media/bomb.png", "word/unexpected.dat"]) {
const zip = await JSZip.loadAsync(await source("navigation.docx"));
zip.file(filename, png);
await assert.rejects(
prepareDocument(await zip.generateAsync({ type: "arraybuffer" }), "docx"),
/pixel/i,
);
}
});
test("Word leaves the worker as a transferable model, not an archive", async () => {
const result = await prepareDocument(await source("navigation.docx"), "docx");
assert.equal(typeof result.bytes, "undefined");
assert.ok(result.word.documentPart.body.children.length > 0);
assert.equal(result.headings.length, 3);
assert.doesNotThrow(() => structuredClone(result));
});
test("PowerPoint leaves the worker as converted bounded slide HTML", async () => {
const result = await prepareDocument(
await source("project-review.pptx"),
"pptx",
);
assert.equal(typeof result.bytes, "undefined");
assert.equal(result.slides.length, 2);
assert.ok(result.slides.every((slide) => slide.includes("slide")));
});
test("PPT image-reference amplification is bounded before conversion", async () => {
const zip = await JSZip.loadAsync(await source("project-review.pptx"));
zip.file(
"ppt/slides/slide1.xml",
'<p:sld xmlns:p="urn:p" xmlns:a="urn:a">' +
"<a:blip/>".repeat(10000) +
"</p:sld>",
);
const bytes = await zip.generateAsync({
type: "arraybuffer",
compression: "STORE",
});
assert.ok(bytes.byteLength < LIMITS.inputBytes);
assert.ok(preflightZip(bytes).length < LIMITS.entries);
await assert.rejects(prepareDocument(bytes, "pptx"), /reuse.*limit/i);
});

View File

@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import test from "node:test";
import JSZip from "jszip";
import { preflightZip, inspectXml, LIMITS } from "../ui/resource-limits.js";
import { prepareOffice } from "../ui/worker-client.js";
async function archive(name = "word/document.xml", value = "<document/>") {
return new JSZip()
.file(name, value)
.generateAsync({ type: "arraybuffer", compression: "STORE" });
}
test("ZIP preflight accepts standard OOXML before any parser is loaded", async () => {
const bytes = await archive();
assert.ok(
preflightZip(bytes).some((entry) => entry.name === "word/document.xml"),
);
});
test("ZIP preflight rejects forged expansion, truncated directories and duplicate local records", async () => {
const bytes = await archive();
const view = new DataView(bytes);
const entries = preflightZip(bytes);
view.setUint32(entries[0].directoryOffset + 24, 101 * 1024 * 1024, true);
assert.throws(() => preflightZip(bytes), /large|limit/i);
assert.throws(() => preflightZip(bytes.slice(0, -1)), /ZIP|archive/i);
const duplicate = await new JSZip()
.file("a", "x")
.file("b", "y")
.generateAsync({ type: "arraybuffer" });
const records = preflightZip(duplicate);
new DataView(duplicate).setUint32(
records[1].directoryOffset + 42,
records[0].localOffset,
true,
);
assert.throws(() => preflightZip(duplicate), /ZIP|archive|overlap/i);
});
test("ZIP preflight rejects ZIP64, encryption, unsupported compression and excessive ratios", async () => {
for (const [field, value] of [
[20, 0xffffffff],
[8, 1],
[10, 99],
[24, 1000000],
]) {
const bytes = await archive();
const entry = preflightZip(bytes)[0];
const view = new DataView(bytes);
if (field === 8 || field === 10)
view.setUint16(entry.directoryOffset + field, value, true);
else view.setUint32(entry.directoryOffset + field, value, true);
assert.throws(() => preflightZip(bytes), /ZIP|archive|limit|large|ratio/i);
}
});
test("XML complexity rejects crafted documents well below original ZIP byte limits", () => {
for (const kind of ["xlsx", "docx", "pptx"]) {
assert.throws(
() =>
inspectXml(
"<root>" + "<a/>".repeat(LIMITS.xmlElements + 1) + "</root>",
kind,
),
/complex|limit/i,
);
assert.throws(
() => inspectXml("<a>".repeat(70) + "</a>".repeat(70), kind),
/depth|complex|limit/i,
);
assert.throws(
() => inspectXml('<!DOCTYPE x [<!ENTITY e "abc">]><x>&e;</x>', kind),
/DTD|DOCTYPE/i,
);
assert.throws(() => inspectXml("<a></b>", kind));
}
});
test("XML accepts real namespaces, styles and text while enforcing document-wide budgets", () => {
const budget = { elements: 0, text: 0, units: 0 };
inspectXml(
'<w:document xmlns:w="urn:word"><w:p><w:r><w:t>Search target</w:t></w:r></w:p></w:document>',
"docx",
budget,
);
assert.ok(budget.elements > 0);
assert.throws(
() =>
inspectXml(
"<r>" + "<p/>".repeat(LIMITS.renderUnits + 1) + "</r>",
"docx",
),
/complex|limit/i,
);
});
class FakeWorker {
terminated = false;
postMessage() {}
terminate() {
this.terminated = true;
}
}
test("deadline terminates an unresponsive parser instead of merely rejecting its promise", async () => {
const worker = new FakeWorker();
await assert.rejects(
prepareOffice(new ArrayBuffer(2), "xlsx", new AbortController().signal, {
timeoutMs: 10,
workerFactory: () => worker,
}),
/timed out/i,
);
assert.equal(worker.terminated, true);
});
test("cancellation terminates the worker and ignores a late result", async () => {
const worker = new FakeWorker(),
controller = new AbortController();
const pending = prepareOffice(new ArrayBuffer(2), "docx", controller.signal, {
workerFactory: () => worker,
});
const late = worker.onmessage;
controller.abort();
await assert.rejects(pending, { name: "AbortError" });
assert.equal(worker.terminated, true);
late?.({ data: { result: { stale: true } } });
});
test("worker failures terminate the parser and already-aborted requests never start it", async () => {
const worker = new FakeWorker();
const pending = prepareOffice(
new ArrayBuffer(2),
"pptx",
new AbortController().signal,
{ workerFactory: () => worker },
);
worker.onerror({ preventDefault() {} });
await assert.rejects(pending, /preview|worker/i);
assert.equal(worker.terminated, true);
const controller = new AbortController();
controller.abort();
await assert.rejects(
prepareOffice(new ArrayBuffer(2), "pptx", controller.signal, {
workerFactory: () => assert.fail("must not create worker"),
}),
{ name: "AbortError" },
);
});
test("rejects small XML that amplifies merge allocation, nested layout or recursive styles", () => {
assert.throws(
() => inspectXml('<r><mergeCell ref="A1:XFD1048576"/></r>', "xlsx"),
/complex|limit/i,
);
assert.throws(
() =>
inspectXml(
"<r>" + "<tbl>".repeat(5) + "</tbl>".repeat(5) + "</r>",
"docx",
),
/complex|limit/i,
);
assert.throws(
() =>
inspectXml(
'<r><style styleId="a"><basedOn val="b"/></style><style styleId="b"><basedOn val="a"/></style></r>',
"docx",
),
/complex|limit/i,
);
});
test("render budgets exclude unused style metadata while XML budgets still apply", () => {
const budget = { elements: 0, text: 0, units: 0 };
inspectXml("<r>" + "<n/>".repeat(21000) + "</r>", "docx", budget, false);
inspectXml("<r><p>Visible</p></r>", "docx", budget);
assert.equal(budget.renderElements, 2);
assert.ok(budget.elements > 20000);
});
test("workbook merge budgets accumulate across parts and sparse coordinates are bounded", () => {
const budget = { elements: 0, text: 0, units: 0 };
inspectXml('<r><mergeCell ref="A1:CV600"/></r>', "xlsx", budget);
assert.throws(
() => inspectXml('<r><mergeCell ref="A1:CV600"/></r>', "xlsx", budget),
/complex|limit/i,
);
assert.throws(
() =>
inspectXml('<r><row r="1048576"><c r="XFD1048576"/></row></r>', "xlsx"),
/complex|limit/i,
);
assert.throws(
() => inspectXml('<r><pgSz w="1000000" h="1000000"/></r>', "docx"),
/complex|limit/i,
);
});
test("worker phase messages never complete a pending conversion", async () => {
const worker = new FakeWorker();
let settled = false;
const pending = prepareOffice(
new ArrayBuffer(2),
"docx",
new AbortController().signal,
{ workerFactory: () => worker },
).then((result) => {
settled = true;
return result;
});
worker.onmessage({ data: { phase: "docx" } });
await Promise.resolve();
assert.equal(settled, false);
worker.onmessage({ data: { result: { word: {} } } });
assert.deepEqual(await pending, { word: {} });
assert.equal(worker.terminated, true);
});
test("sparse PowerPoint chart indices cannot amplify small XML into giant arrays", () => {
assert.throws(
() =>
inspectXml(
'<c:root xmlns:c="urn:chart"><c:pt idx="4294967294"><c:v>1</c:v></c:pt></c:root>',
"pptx",
),
/complex|limit/i,
);
});

View File

@ -0,0 +1,58 @@
"""Offline registration and packaged browser-asset checks."""
import json
import sys
import unittest
from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "backend/packages/extension-api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
install = import_module("deerflow_extension_office_preview").install
class RegistrationTests(unittest.TestCase):
def test_registration_has_only_the_browser_contribution(self):
contributions = []
install(
SimpleNamespace(plugin=lambda value: contributions.append(value) or True),
{},
)
contribution = contributions[0]
self.assertEqual(contribution.namespace, "office-preview")
self.assertTrue(contribution.enabled)
self.assertFalse(contribution.backend)
self.assertFalse(contribution.tools)
manifest = json.loads(
(contribution.frontend.root / "ui_manifest.json").read_text(
encoding="utf-8"
)
)
self.assertIn(manifest["entry"], manifest["files"])
self.assertEqual(len(set(manifest["files"])), len(manifest["files"]))
for filename in manifest["files"]:
asset = contribution.frontend.root / filename
self.assertTrue(asset.is_file())
self.assertLess(asset.stat().st_size, 4 * 1024 * 1024)
def test_disabled_and_invalid_configuration(self):
contributions = []
registry = SimpleNamespace(
plugin=lambda value: contributions.append(value) or True
)
install(registry, {"enabled": False})
self.assertFalse(contributions[0].enabled)
for enabled in ("false", 1, None):
with self.assertRaises(ValueError):
install(registry, {"enabled": enabled})
def test_unsupported_host_fails_clearly(self):
with self.assertRaises(RuntimeError):
install(SimpleNamespace(plugin=lambda value: False), {})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,57 @@
/** Resolve only package-local relationships, with the same POSIX path semantics as OOXML. */
export function resolvePartPath(base, target) {
const segments = target.startsWith("/") ? [] : base.split("/").slice(0, -1);
for (const part of target.split("/")) {
if (part === "..") segments.pop();
else if (part && part !== ".") segments.push(part);
}
return segments.join("/");
}
/** Reject amplified parser output before structured clone or DOM creation. */
export function checkModelBudget(value) {
const seen = new Set(),
stack = [value];
let nodes = 0,
bytes = 0;
while (stack.length) {
const item = stack.pop();
if (typeof item === "string") bytes += item.length * 2;
else if (item && typeof item === "object" && !seen.has(item)) {
seen.add(item);
if (++nodes > 100000) throw new Error("Office model node limit exceeded");
if (item instanceof ArrayBuffer) bytes += item.byteLength;
else if (ArrayBuffer.isView(item)) {
if (!seen.has(item.buffer)) {
seen.add(item.buffer);
bytes += item.buffer.byteLength;
}
} else {
if (Array.isArray(item) && item.length > 100000)
throw new Error("Office model array limit exceeded");
for (const [key, child] of Object.entries(item)) {
bytes += key.length * 2;
stack.push(child);
}
}
}
if (bytes > 32 * 1024 * 1024)
throw new Error("Office model size limit exceeded");
}
}
/** Transfer each resource once; do not clone validated image bytes back to the UI. */
export function transferBuffers(value) {
const seen = new Set(),
buffers = new Set(),
stack = [value];
while (stack.length) {
const item = stack.pop();
if (!item || typeof item !== "object" || seen.has(item)) continue;
seen.add(item);
if (item instanceof ArrayBuffer) buffers.add(item);
else if (ArrayBuffer.isView(item)) buffers.add(item.buffer);
else stack.push(...Object.values(item));
}
return [...buffers];
}

View File

@ -0,0 +1,19 @@
/**
* Allocate unique identifiers without interpreting existing names or requiring crypto.
* The input is consumed once; each candidate is tested at most once per allocator.
* @param {Iterable<string>} existing
* @param {string} prefix
* @param {number} start
*/
export function createUniqueIdAllocator(existing, prefix = "", start = 0) {
const used = new Set(existing);
let next = start;
return () => {
let id;
do {
id = `${prefix}${next++}`;
} while (used.has(id));
used.add(id);
return id;
};
}

View File

@ -0,0 +1,37 @@
export default {
apiVersion: 1,
module: "office-preview",
artifactPreviews: [
{
id: "office",
extensions: ["xlsx", "docx", "pptx"],
mount(root, context) {
const controller = new AbortController();
const abort = () => controller.abort();
context.signal.addEventListener("abort", abort, { once: true });
if (context.signal.aborted) abort();
let dispose = () => {};
import("./office.js")
.then(async ({ mountOffice }) => {
if (controller.signal.aborted) return;
dispose = await mountOffice(root, {
...context,
signal: controller.signal,
});
if (controller.signal.aborted) dispose();
})
.catch(() => {
if (!controller.signal.aborted) context.onError();
});
return {
dispose() {
controller.abort();
context.signal.removeEventListener("abort", abort);
dispose();
root.replaceChildren();
},
};
},
},
],
};

View File

@ -0,0 +1,17 @@
import { prepareDocument } from "./prepare-document.js";
import { transferBuffers } from "./document-model.js";
self.onmessage = async ({ data: { bytes, kind } }) => {
try {
const result = await prepareDocument(bytes, kind, (phase) =>
self.postMessage({ phase }),
);
self.postMessage({ result }, transferBuffers(result));
} catch {
// Parser exceptions can contain document contents: expose a fixed diagnostic.
self.postMessage({
error:
"Office file exceeds preview limits or cannot be read. Download the original to open it.",
});
}
};

View File

@ -0,0 +1,959 @@
import { highlightText, MAX_SEARCH_MATCHES } from "./search.js";
import { prepareOffice } from "./worker-client.js";
import { workbookViews } from "./workbook-view.js";
import { wordView } from "./word-view.js";
const styles = `
:host{display:block;height:100%;min-width:0}*{box-sizing:border-box}.office{height:100%;display:flex;flex-direction:column;font:13px system-ui,sans-serif;color:var(--foreground);background:var(--background)}
.bar,.tabs{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);flex-wrap:wrap}.bar button,.tabs button{color:inherit;background:var(--background);border:1px solid var(--border);border-radius:6px;min-width:28px;height:28px;cursor:pointer}.bar button:hover,.tabs button:hover{background:var(--accent)}button:focus-visible,input:focus-visible,td:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.muted{color:var(--muted-foreground);font-size:12px}.spacer{flex:1}.bar input{min-width:80px;width:120px;background:var(--background);color:inherit;border:1px solid var(--border);border-radius:6px;padding:5px 7px}.canvas{flex:1;min-height:0;overflow:auto;background:var(--muted);padding:20px;overscroll-behavior:contain}.paper{width:max-content;min-width:100%;transform-origin:top left}.tabs{border-bottom:0;border-top:1px solid var(--border);padding:8px 12px;overflow:auto;flex-wrap:nowrap}.tabs button{white-space:nowrap;padding:3px 14px}.tabs button[aria-selected=true]{border-color:var(--ring);background:var(--accent);font-weight:600}.formula{padding:8px 12px;min-height:34px;border-bottom:1px solid var(--border);font-family:monospace;white-space:nowrap;overflow:auto}.table{border-collapse:separate;border-spacing:0;background:var(--background);table-layout:fixed;user-select:text}.table th,.table td{padding:8px 10px;min-width:90px;max-width:360px;height:32px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);white-space:pre-wrap;overflow-wrap:anywhere}.table th{position:sticky;top:0;background:var(--muted);color:var(--muted-foreground);font:11px system-ui;z-index:1;text-align:center}.table th:first-child{left:0;min-width:42px;width:42px;z-index:2}.table td:focus{box-shadow:inset 0 0 0 2px var(--ring)}.note{padding:8px 12px;background:var(--accent);color:var(--accent-foreground);font-size:12px}.empty{padding:40px;text-align:center;color:var(--muted-foreground)}mark{background:#ffe478;color:#202020}.docx-wrapper{background:transparent!important;padding:0!important}.docx-wrapper>section.docx{box-shadow:0 2px 12px #0002;margin:0 auto 24px!important}.docx a{pointer-events:none;color:inherit;text-decoration:none}.slide{border:0;display:block;background:white;box-shadow:0 2px 12px #0002}.pages{display:flex;gap:8px;align-items:center}.slides-nav{display:flex;gap:6px;max-width:100%;overflow:auto}.slides-nav button{flex-shrink:0}.slides-nav button[aria-current=true]{outline:2px solid var(--ring)}
`;
const interactionStyles = `
button:disabled{opacity:.4;cursor:default}.bar button[aria-pressed=true]{background:var(--accent);border-color:var(--ring)}.cell-tools{display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);padding:6px 12px;flex-wrap:wrap}.cell-tools form{display:flex;align-items:center;gap:4px}.cell-tools input{width:74px;min-width:0;padding:6px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:inherit;font:12px monospace}.cell-tools input[aria-invalid=true]{border-color:var(--destructive)}.cell-tools button{min-height:30px;background:var(--background);color:inherit;border:1px solid var(--border);border-radius:6px;padding:3px 8px;cursor:pointer}.cell-tools .formula{flex:1;min-width:100px;padding:4px;border:0;min-height:0}.cell-status{font-size:12px;color:var(--muted-foreground)}.table td[data-selected=true]{box-shadow:inset 0 0 0 2px var(--ring)}.canvas:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}
`;
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const button = (text, label, action) => {
const node = element("button", "", text);
node.type = "button";
node.setAttribute("aria-label", label);
node.title = label;
node.addEventListener("click", action);
return node;
};
export async function mountOffice(root, context) {
const zh = context.locale.startsWith("zh");
const tr = (en, cn) => (zh ? cn : en);
const style = element("style");
style.textContent =
styles +
interactionStyles +
`
.reader-body{display:flex;flex:1;min-height:0;min-width:0}.reader-body>.canvas{min-width:0}.navigation{width:160px;flex-shrink:0;overflow:auto;padding:8px;border-right:1px solid var(--border);background:var(--background)}.navigation[hidden]{display:none}.navigation button{display:block;width:100%;text-align:left;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:inherit;cursor:pointer;overflow-wrap:anywhere}.navigation button[aria-current=true]{border-color:var(--ring);background:var(--accent)}.thumbnail{display:block;width:128px;height:72px;overflow:hidden;pointer-events:none;background:white;margin-bottom:6px}.thumbnail iframe{width:960px;height:540px;border:0;transform:scale(.1333333);transform-origin:top left}.find{display:flex;align-items:center;gap:6px;padding:8px 12px;flex-wrap:wrap;border-bottom:1px solid var(--border)}.find input{min-width:100px;flex:1;width:140px;background:var(--background);color:inherit;border:1px solid var(--border);border-radius:6px;padding:6px}.find button{border:1px solid var(--border);border-radius:6px;color:inherit;background:var(--background);min-width:30px;min-height:30px;cursor:pointer}.search-scope{flex-basis:100%;font-size:11px;color:var(--muted-foreground)}mark[data-current=true]{outline:2px solid var(--ring)}@media(max-width:600px){.navigation{width:120px}.thumbnail{width:96px;height:54px}.thumbnail iframe{transform:scale(.1)}}`;
const shell = element("div", "office");
shell.append(
element("div", "empty", tr("Preparing preview…", "正在准备预览…")),
);
root.replaceChildren(style, shell);
const kind = context.filename.split(".").pop().toLowerCase();
const prepared = await prepareOffice(
await context.readFile(),
kind,
context.signal,
);
context.signal.throwIfAborted();
const saved = context.getReadingState?.();
const reading = saved?.version === 1 ? saved : {};
context.signal.throwIfAborted();
shell.replaceChildren();
const bar = element("div", "bar");
bar.append(
element(
"strong",
"",
kind === "xlsx" ? "Excel" : kind === "docx" ? "Word" : "PowerPoint",
),
);
bar.append(
element("span", "muted", tr("Preview", "预览")),
element("span", "spacer"),
);
const canvas = element("div", "canvas");
const paper = element("div", "paper");
canvas.append(paper);
const foot = element("div", "tabs");
let zoom = 1;
const zoomText = element("span", "muted", "100%");
let fitting = false;
const setZoom = (value, fit = false) => {
const previousZoom = zoom;
const left = canvas.scrollLeft,
top = canvas.scrollTop;
const atBottom =
canvas.scrollHeight > canvas.clientHeight &&
canvas.scrollHeight - top - canvas.clientHeight <= 2;
fitting = fit;
zoom = Math.max(0.25, Math.min(2, value));
paper.style.zoom = String(zoom);
zoomText.textContent = `${Math.round(zoom * 100)}%`;
zoomOut.disabled = zoom <= 0.25;
zoomIn.disabled = zoom >= 2;
fitButton.setAttribute("aria-pressed", String(fitting));
// Keep the same content under the viewport when its layout scale changes.
if (previousZoom !== zoom)
canvas.scrollTo(
(left * zoom) / previousZoom,
atBottom
? canvas.scrollHeight - canvas.clientHeight
: (top * zoom) / previousZoom,
);
};
const fitWidth = () => {
const content = paper.querySelector(
kind === "xlsx" ? "table" : kind === "docx" ? "section.docx" : "iframe",
);
setZoom(
Math.min(
1,
Math.max(1, canvas.clientWidth - 40) / (content?.offsetWidth || 794),
),
true,
);
};
const zoomOut = button("−", tr("Zoom out", "缩小"), () =>
setZoom(zoom - 0.1),
);
const zoomIn = button("+", tr("Zoom in", "放大"), () => setZoom(zoom + 0.1));
const fitButton = button("↔", tr("Fit width", "适应宽度"), fitWidth);
fitButton.setAttribute("aria-pressed", "false");
bar.append(zoomOut, zoomText, zoomIn, fitButton);
const body = element("div", "reader-body");
body.append(canvas);
shell.append(bar, body, foot);
const disposals = [];
let capture = () => ({});
let ready = false;
const save = () => {
if (ready && !context.signal.aborted)
context.saveReadingState?.({
version: 1,
zoom,
fitting,
scroll: [canvas.scrollLeft, canvas.scrollTop],
...capture(),
});
};
canvas.addEventListener("scroll", () => queueMicrotask(save));
bar.addEventListener("click", () => queueMicrotask(save));
canvas.addEventListener("wheel", () => queueMicrotask(save));
const find = element("div", "find");
const query = element("input");
query.type = "search";
query.disabled = true;
query.setAttribute("aria-label", tr("Find in file", "在文件中查找"));
query.placeholder = tr("Find in file…", "在文件中查找…");
const matchCount = element("span", "muted");
matchCount.setAttribute("role", "status");
matchCount.setAttribute("aria-label", tr("Search matches", "搜索匹配"));
const scope = element(
"span",
"search-scope",
kind === "xlsx"
? tr(
"Search visible cells across all visible sheets, within the first 500 rows and 50 columns per sheet. Hidden cells and formulas are excluded.",
"搜索所有可见工作表前 500 行、50 列中的可见单元格内容;不含隐藏单元格和公式。",
)
: kind === "docx"
? tr(
"Search rendered document text; embedded objects are excluded.",
"搜索已渲染的文档文字,不含嵌入对象。",
)
: tr(
"Search slide text; text inside images, charts and embedded objects is excluded.",
"搜索幻灯片文字,不含图片、图表和嵌入对象内的文字。",
),
);
scope.textContent += tr(
" Shows up to 2,000 matches.",
" 最多显示 2,000 处匹配。",
);
let matches = [],
current = -1;
let search = () => {};
let searchTimer;
const flushSearch = () => {
if (searchTimer === undefined) return false;
clearTimeout(searchTimer);
searchTimer = undefined;
search();
return true;
};
disposals.push(() => clearTimeout(searchTimer));
const updateMatches = (items) => {
matches = items;
current = -1;
previousMatch.disabled = nextMatch.disabled = !items.length;
matchCount.textContent = query.value
? tr(`${items.length} matches`, `${items.length} 处匹配`)
: tr("Find text", "查找文字");
};
const moveMatch = (delta) => {
if (!matches.length) return;
current =
current < 0
? delta < 0
? matches.length - 1
: 0
: (current + delta + matches.length) % matches.length;
Promise.resolve(matches[current]()).catch(() => {
if (!context.signal.aborted) context.onError();
});
matchCount.textContent = `${current + 1} / ${matches.length}`;
save();
};
const previousMatch = button("↑", tr("Previous match", "上一处匹配"), () =>
moveMatch(-1),
);
const nextMatch = button("↓", tr("Next match", "下一处匹配"), () =>
moveMatch(1),
);
previousMatch.disabled = nextMatch.disabled = true;
find.append(query, matchCount, previousMatch, nextMatch, scope);
shell.insertBefore(find, body);
query.addEventListener("input", () => {
clearTimeout(searchTimer);
updateMatches([]);
searchTimer = setTimeout(() => {
searchTimer = undefined;
search();
if (matches.length) moveMatch(1);
}, 200);
});
query.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
flushSearch();
moveMatch(event.shiftKey ? -1 : 1);
}
if (event.key === "Escape") {
query.value = "";
if (!flushSearch()) search();
}
});
shell.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
event.preventDefault();
query.focus();
query.select();
}
});
const reveal = (groups, index) => {
paper
.querySelectorAll("mark[data-current]")
.forEach((mark) => delete mark.dataset.current);
for (const mark of groups[index] ?? []) mark.dataset.current = "true";
groups[index]?.[0]?.scrollIntoView({ block: "center", inline: "nearest" });
};
canvas.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
setZoom(zoom + (event.deltaY < 0 ? 0.1 : -0.1));
}
},
{ passive: false },
);
if (kind === "xlsx") {
const sheets = workbookViews(prepared.sheets);
const tools = element("div", "cell-tools");
const jump = element("form");
const address = element("input");
address.setAttribute("aria-label", tr("Go to cell", "跳转到单元格"));
address.placeholder = "A1";
address.autocomplete = "off";
address.maxLength = 12;
const jumpButton = button(
tr("Go", "跳转"),
tr("Go to cell address", "跳转到单元格地址"),
() => jump.requestSubmit(),
);
const formula = element("output", "formula");
formula.setAttribute(
"aria-label",
tr("Cell value or formula", "单元格内容或公式"),
);
const status = element("span", "cell-status");
status.setAttribute("role", "status");
status.setAttribute(
"aria-label",
tr("Cell action status", "单元格操作状态"),
);
let selected;
const copy = button(
tr("Copy", "复制"),
tr("Copy cell value", "复制单元格内容"),
async () => {
if (!selected) return;
try {
await navigator.clipboard.writeText(selected.text);
if (!context.signal.aborted)
status.textContent = tr("Copied", "已复制");
} catch {
if (!context.signal.aborted)
status.textContent = tr(
"Select the value to copy manually.",
"请选择内容后手动复制。",
);
}
},
);
copy.disabled = true;
jump.append(address, jumpButton);
tools.append(jump, formula, copy, status);
shell.insertBefore(tools, body);
const tablist = element("div", "slides-nav");
tablist.setAttribute("role", "tablist");
tablist.setAttribute("aria-label", tr("Worksheets", "工作表"));
foot.append(tablist);
const state = new Map(Array.isArray(reading.sheets) ? reading.sheets : []);
let active;
const paintSearch = () => {
const groups = new Map();
let remaining = MAX_SEARCH_MATCHES;
const value = query.value.trim();
const pattern = value
? new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu")
: null;
for (const td of paper.querySelectorAll("td")) {
if (
td.querySelector("mark[data-office-match]") ||
pattern?.test(td.textContent)
) {
const highlights = highlightText(td, value, remaining);
remaining -= highlights.length;
groups.set(td.dataset.address, highlights);
}
}
return groups;
};
let sheetRevision = 0;
const renderSheet = async (sheet) => {
const revision = ++sheetRevision;
context.signal.throwIfAborted();
if (active)
state.set(active.id, {
scroll: [canvas.scrollLeft, canvas.scrollTop],
address: selected?.address,
});
active = sheet;
selected = undefined;
formula.textContent = tr(
"Select a cell to inspect its value or formula",
"选择单元格查看内容或公式",
);
copy.disabled = true;
address.value = "";
status.textContent = "";
address.removeAttribute("aria-invalid");
for (const tab of tablist.children) {
const current = tab.dataset.sheet === String(sheet.id);
tab.setAttribute("aria-selected", String(current));
tab.tabIndex = current ? 0 : -1;
}
paper.replaceChildren();
const rows = Math.min(sheet.rowCount, 500),
cols = Math.min(sheet.columnCount, 50);
const table = element("table", "table");
table.setAttribute("aria-label", sheet.name);
const head = element("thead"),
headers = element("tr");
headers.append(element("th", "", ""));
const letter = (column) => {
let label = "";
for (let n = column; n; n = Math.floor((n - 1) / 26))
label = String.fromCharCode(65 + ((n - 1) % 26)) + label;
return label;
};
for (let c = 1; c <= cols; c++) {
if (!sheet.getColumn(c).hidden)
headers.append(element("th", "", letter(c)));
}
head.append(headers);
table.append(head);
const tableBody = element("tbody");
table.append(tableBody);
if (rows && cols) paper.append(table);
for (let r = 1; r <= rows; r++) {
if (r % 16 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
context.signal.throwIfAborted();
if (revision !== sheetRevision) return;
}
const row = sheet.getRow(r);
if (row.hidden) continue;
const trNode = element("tr");
trNode.append(element("th", "", String(r)));
for (let c = 1; c <= cols; c++) {
if (sheet.getColumn(c).hidden) continue;
const cell = row.getCell(c);
if (cell.isMerged && cell.master.address !== cell.address) continue;
const td = element("td", "", cell.text);
let rowEnd = r,
colEnd = c;
const merge = sheet._merges[`${r}:${c}`];
if (merge) {
const m = merge.model;
if (m.top === r && m.left === c) {
rowEnd = Math.min(m.bottom, rows);
colEnd = Math.min(m.right, cols);
td.rowSpan = Array.from(
{ length: rowEnd - r + 1 },
(_, i) => r + i,
).filter((n) => !sheet.getRow(n).hidden).length;
td.colSpan = Array.from(
{ length: colEnd - c + 1 },
(_, i) => c + i,
).filter((n) => !sheet.getColumn(n).hidden).length;
}
}
td.tabIndex = -1;
td.dataset.address = cell.address;
td.dataset.row = String(r);
td.dataset.col = String(c);
// Keep workbook coordinates separate from rendered spans, which exclude hidden rows/columns.
td.dataset.rowEnd = String(rowEnd);
td.dataset.colEnd = String(colEnd);
td.style.width = `${Math.max(80, Math.min(360, (sheet.getColumn(c).width ?? 12) * 8))}px`;
if (sheet.getColumn(c).hidden) td.hidden = true;
td.style.fontWeight = cell.font?.bold ? "600" : "normal";
td.style.fontStyle = cell.font?.italic ? "italic" : "normal";
const color = cell.font?.color?.argb,
fill = cell.fill?.fgColor?.argb;
if (/^[A-Fa-f0-9]{8}$/.test(color ?? ""))
td.style.color = `#${color.slice(2)}`;
if (/^[A-Fa-f0-9]{8}$/.test(fill ?? ""))
td.style.backgroundColor = `#${fill.slice(2)}`;
if (["left", "center", "right"].includes(cell.alignment?.horizontal))
td.style.textAlign = cell.alignment.horizontal;
td.selectCell = () => {
for (const previous of paper.querySelectorAll(
"td[data-selected=true]",
)) {
previous.tabIndex = -1;
delete previous.dataset.selected;
}
td.tabIndex = 0;
td.dataset.selected = "true";
selected = cell;
copy.disabled = false;
address.value = cell.address;
address.removeAttribute("aria-invalid");
status.textContent = "";
formula.textContent = `${cell.address} ${cell.formula ? `= ${cell.formula} → ` : ""}${cell.text}`;
};
td.addEventListener("focus", td.selectCell);
trNode.append(td);
}
tableBody.append(trNode);
}
if (!rows || !cols)
paper.append(
element(
"div",
"empty",
tr("This worksheet is empty.", "此工作表为空。"),
),
);
let note = shell.querySelector(".note");
note?.remove();
if (sheet.rowCount > rows || sheet.columnCount > cols) {
note = element(
"div",
"note",
tr(
`Showing the first ${rows} rows and ${cols} columns. Download for the complete workbook.`,
`仅预览前 ${rows} 行、${cols} 列,可下载完整工作簿。`,
),
);
shell.insertBefore(note, body);
}
const saved = state.get(sheet.id);
const initial =
(saved?.address &&
paper.querySelector(`td[data-address="${saved.address}"]`)) ||
paper.querySelector("td");
initial?.selectCell();
const scroll = saved?.scroll ?? [0, 0];
canvas.scrollTo(scroll[0], scroll[1]);
if (fitting) fitWidth();
paintSearch();
save();
};
for (const sheet of sheets) {
const tab = button(sheet.name, sheet.name, () => {
renderSheet(sheet).catch((error) => {
if (!context.signal.aborted && error.name !== "AbortError")
context.onError();
});
});
tab.setAttribute("role", "tab");
tab.dataset.sheet = String(sheet.id);
tablist.append(tab);
}
if (sheets.length)
await renderSheet(
sheets.find((sheet) => sheet.id === reading.sheet) ?? sheets[0],
);
else
paper.append(
element(
"div",
"empty",
tr("No visible worksheets.", "没有可见工作表。"),
),
);
capture = () => ({
sheet: active?.id,
sheets: [
...[...state.entries()].filter(([id]) => id !== active?.id),
...(active
? [
[
active.id,
{
address: selected?.address,
scroll: [canvas.scrollLeft, canvas.scrollTop],
},
],
]
: []),
].slice(-50),
});
search = () => {
const items = [];
const value = query.value.trim();
if (value) {
const pattern = new RegExp(
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"giu",
);
searchCells: for (const sheet of sheets) {
for (let row = 1; row <= Math.min(sheet.rowCount, 500); row++) {
if (sheet.getRow(row).hidden) continue;
for (let col = 1; col <= Math.min(sheet.columnCount, 50); col++) {
if (sheet.getColumn(col).hidden) continue;
const cell = sheet.getCell(row, col);
if (cell.isMerged && cell.master.address !== cell.address)
continue;
let index = 0;
for (const _match of cell.text.matchAll(pattern)) {
if (items.length >= MAX_SEARCH_MATCHES) break searchCells;
const occurrence = index++;
items.push(async () => {
if (active !== sheet) await renderSheet(sheet);
if (context.signal.aborted || active !== sheet) return;
const groups = paintSearch().get(cell.address);
const td = paper.querySelector(
`td[data-address="${cell.address}"]`,
);
td?.selectCell();
reveal(groups ?? [], occurrence);
});
}
}
}
}
}
paintSearch();
updateMatches(items);
};
paper.addEventListener("focusin", () => queueMicrotask(save));
jump.addEventListener("submit", (event) => {
event.preventDefault();
const value = address.value.trim().toUpperCase();
const match = /^([A-Z]{1,3})([1-9][0-9]{0,5})$/.exec(value);
const row = match ? Number(match[2]) : 0;
const col = match
? [...match[1]].reduce(
(n, letter) => n * 26 + letter.charCodeAt(0) - 64,
0,
)
: 0;
const target =
match &&
[...paper.querySelectorAll("td")].find(
(cell) =>
Number(cell.dataset.row) <= row &&
Number(cell.dataset.rowEnd) >= row &&
Number(cell.dataset.col) <= col &&
Number(cell.dataset.colEnd) >= col,
);
if (target) {
target.focus();
target.scrollIntoView({ block: "nearest", inline: "nearest" });
} else {
address.setAttribute("aria-invalid", "true");
status.textContent = tr(
"Cell is outside the visible preview.",
"此单元格不在可见预览范围内。",
);
}
});
tablist.addEventListener("keydown", (event) => {
const tabs = [...tablist.children],
index = tabs.indexOf(event.target);
if (
index < 0 ||
!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)
)
return;
event.preventDefault();
const next =
event.key === "Home"
? 0
: event.key === "End"
? tabs.length - 1
: (index + (event.key === "ArrowLeft" ? -1 : 1) + tabs.length) %
tabs.length;
tabs[next].click();
tabs[next].focus();
});
paper.addEventListener("keydown", (event) => {
const delta = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
}[event.key];
const cell = event.target.closest("td");
if (!delta || !cell) return;
const cells = [...paper.querySelectorAll("td")];
const row = Number(cell.dataset.row),
col = Number(cell.dataset.col);
const target = cells
.filter((candidate) =>
delta[0]
? Number(candidate.dataset.col) <= col &&
Number(candidate.dataset.colEnd) >= col &&
(Number(candidate.dataset.row) - row) * delta[0] > 0
: Number(candidate.dataset.row) <= row &&
Number(candidate.dataset.rowEnd) >= row &&
(Number(candidate.dataset.col) - col) * delta[1] > 0,
)
.sort((a, b) =>
delta[0]
? (Number(a.dataset.row) - Number(b.dataset.row)) * delta[0]
: (Number(a.dataset.col) - Number(b.dataset.col)) * delta[1],
)[0];
event.preventDefault();
if (target) {
event.preventDefault();
target.focus();
}
});
} else if (kind === "docx") {
const { renderDocument } = await import("docx-preview");
const headings = prepared.headings;
const view = wordView(prepared);
disposals.push(() => view.dispose());
const release = () => view.dispose();
context.signal.addEventListener("abort", release, { once: true });
disposals.push(() => context.signal.removeEventListener("abort", release));
await new Promise((resolve) => setTimeout(resolve, 0));
context.signal.throwIfAborted();
try {
await renderDocument(view.document, paper, paper, {
inWrapper: true,
breakPages: true,
ignoreWidth: false,
ignoreHeight: false,
renderAltChunks: false,
useBase64URL: false,
ignoreFonts: true,
});
} catch (error) {
view.dispose();
throw error;
}
context.signal.throwIfAborted();
paper
.querySelectorAll("a")
.forEach((anchor) => anchor.removeAttribute("href"));
const pages = [...paper.querySelectorAll("section.docx")];
const outline = element("nav", "navigation");
outline.setAttribute("aria-label", tr("Document outline", "文档大纲"));
body.prepend(outline);
outline.hidden = reading.navigationOpen === false;
const toggleOutline = button(
tr("Outline", "大纲"),
tr("Toggle document outline", "展开或收起文档大纲"),
() => {
outline.hidden = !outline.hidden;
toggleOutline.setAttribute("aria-expanded", String(!outline.hidden));
if (fitting) fitWidth();
save();
},
);
toggleOutline.setAttribute("aria-expanded", String(!outline.hidden));
bar.insertBefore(toggleOutline, bar.children[2]);
for (const heading of headings) {
const target = paper.querySelector(`[id="${heading.id}"]`);
if (!target) continue;
const item = button(heading.title, heading.title, () => {
canvas.scrollTop +=
target.getBoundingClientRect().top -
canvas.getBoundingClientRect().top -
20;
outline
.querySelectorAll("button")
.forEach((entry) =>
entry.setAttribute("aria-current", String(entry === item)),
);
save();
});
item.style.paddingLeft = `${8 + heading.level * 10}px`;
outline.append(item);
}
if (!outline.children.length)
outline.append(
element(
"p",
"muted",
tr("No document headings.", "文档没有标题大纲。"),
),
);
const counter = element("span", "muted");
let page = 0;
counter.setAttribute("aria-live", "polite");
const update = () => {
counter.textContent = `${pages.length ? page + 1 : 0} / ${pages.length}`;
previous.disabled = page <= 0;
next.disabled = page >= pages.length - 1;
};
const go = (value) => {
page = Math.max(0, Math.min(pages.length - 1, value));
if (pages[page])
canvas.scrollTop +=
pages[page].getBoundingClientRect().top -
canvas.getBoundingClientRect().top -
20;
update();
save();
};
const previous = button("‹", tr("Previous page", "上一页"), () =>
go(page - 1),
);
const next = button("›", tr("Next page", "下一页"), () => go(page + 1));
foot.append(previous, counter, next);
capture = () => ({ page, navigationOpen: !outline.hidden });
search = () => {
const groups = highlightText(paper, query.value.trim());
updateMatches(groups.map((_, index) => () => reveal(groups, index)));
};
update();
canvas.addEventListener("scroll", () => {
const top = canvas.getBoundingClientRect().top + 24;
let closest = 0;
for (let i = 0; i < pages.length; i++)
if (pages[i].getBoundingClientRect().top <= top) closest = i;
if (
canvas.scrollHeight > canvas.clientHeight &&
canvas.scrollHeight - canvas.scrollTop - canvas.clientHeight <= 2
)
closest = pages.length - 1;
if (closest !== page) {
page = closest;
update();
}
});
fitWidth();
} else {
const slides = prepared.slides;
context.signal.throwIfAborted();
const frame = element("iframe", "slide");
frame.width = "960";
frame.height = "540";
frame.setAttribute("sandbox", "");
frame.setAttribute("title", context.filename);
const stripActive = (html, highlight = false, match = -1) => {
const doc = new DOMParser().parseFromString(html, "text/html");
doc
.querySelectorAll(
"script,iframe,object,embed,link,base,meta,form,audio,video",
)
.forEach((node) => node.remove());
doc.querySelectorAll("*").forEach((node) => {
for (const attr of [...node.attributes]) {
if (
attr.name.startsWith("on") ||
["href", "srcdoc", "action"].includes(attr.name) ||
(attr.name === "src" &&
!/^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,/i.test(
attr.value,
))
)
node.removeAttribute(attr.name);
}
});
// Converter text boxes contain anonymous flex text items. Keep each
// contiguous inline run in one item before marks split its text nodes,
// preserving the box's vertical alignment and the surrounding glyphs.
for (const box of doc.querySelectorAll("div")) {
if (!["flex", "inline-flex"].includes(box.style.display)) continue;
let run = [];
const wrap = () => {
if (run.some((node) => node.textContent.trim())) {
const line = doc.createElement("div");
line.style.minWidth = "0";
run[0].before(line);
line.append(...run);
}
run = [];
};
for (const node of [...box.childNodes]) {
if (
node.nodeType === 3 ||
/^(SPAN|B|I|U|S|EM|STRONG|BR|A|SUB|SUP)$/.test(node.nodeName)
)
run.push(node);
else wrap();
}
wrap();
}
if (highlight) {
const groups = highlightText(doc.body, query.value.trim());
for (const mark of groups[match] ?? []) mark.dataset.current = "true";
}
return doc.body.innerHTML;
};
const counter = element("span", "muted");
counter.setAttribute("aria-live", "polite");
let page = 0;
const nav = element("nav", "navigation");
nav.setAttribute("aria-label", tr("Slide thumbnails", "幻灯片缩略图"));
nav.hidden = reading.navigationOpen === false;
body.prepend(nav);
const toggleThumbnails = button(
tr("Thumbnails", "缩略图"),
tr("Toggle slide thumbnails", "展开或收起幻灯片缩略图"),
() => {
nav.hidden = !nav.hidden;
toggleThumbnails.setAttribute("aria-expanded", String(!nav.hidden));
if (fitting) fitWidth();
save();
},
);
toggleThumbnails.setAttribute("aria-expanded", String(!nav.hidden));
bar.insertBefore(toggleThumbnails, bar.children[2]);
const documentOf = (content) =>
`<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:"><style>body{margin:0;overflow:hidden;background:white;color:black}mark{background:#ffe478;color:#202020}mark[data-current=true]{outline:2px solid #2665c7}</style></head><body>${content}</body></html>`;
const go = (value, match = -1) => {
page = Math.max(0, Math.min(slides.length - 1, value));
frame.srcdoc = documentOf(stripActive(slides[page] ?? "", true, match));
counter.textContent = `${page + 1} / ${slides.length}`;
previous.disabled = page === 0;
next.disabled = page === slides.length - 1;
for (let i = 0; i < nav.children.length; i++)
nav.children[i].setAttribute("aria-current", String(i === page));
save();
};
const previous = button("‹", tr("Previous slide", "上一页"), () =>
go(page - 1),
);
const next = button("›", tr("Next slide", "下一页"), () => go(page + 1));
const thumbnailObserver = new IntersectionObserver(
(items) => {
if (context.signal.aborted) return;
for (const item of items) {
const target = item.target.querySelector(".thumbnail");
if (item.isIntersecting && !target.children.length) {
const preview = element("iframe");
preview.setAttribute("sandbox", "");
preview.tabIndex = -1;
preview.setAttribute("aria-hidden", "true");
preview.srcdoc = documentOf(
stripActive(slides[Number(item.target.dataset.slide)]),
);
target.append(preview);
} else if (!item.isIntersecting) target.replaceChildren();
}
},
{ root: nav, rootMargin: "100px" },
);
disposals.push(() => thumbnailObserver.disconnect());
for (let i = 0; i < slides.length; i++) {
const item = button("", tr(`Slide ${i + 1}`, `第 ${i + 1} 页`), () =>
go(i),
);
item.dataset.slide = String(i);
item.append(
element("span", "thumbnail"),
element("span", "muted", String(i + 1)),
);
nav.append(item);
thumbnailObserver.observe(item);
}
paper.append(frame);
foot.append(previous, counter, next);
capture = () => ({ page, navigationOpen: !nav.hidden });
search = () => {
const items = [];
slides.forEach((slide, index) => {
if (items.length >= MAX_SEARCH_MATCHES) return;
const doc = new DOMParser().parseFromString(
stripActive(slide),
"text/html",
);
const groups = highlightText(
doc.body,
query.value.trim(),
MAX_SEARCH_MATCHES - items.length,
);
groups.forEach((_, match) => items.push(() => go(index, match)));
});
updateMatches(items);
go(page);
};
canvas.tabIndex = 0;
canvas.setAttribute(
"aria-label",
tr(
"Slide preview. Use arrow keys to change slides.",
"幻灯片预览,可使用方向键翻页。",
),
);
shell.addEventListener("keydown", (event) => {
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.target.closest(".bar,.find,input,textarea")
)
return;
const value = {
ArrowLeft: page - 1,
ArrowUp: page - 1,
PageUp: page - 1,
ArrowRight: page + 1,
ArrowDown: page + 1,
PageDown: page + 1,
Home: 0,
End: slides.length - 1,
}[event.key];
if (value === undefined) return;
event.preventDefault();
go(value);
});
if (slides.length) go(Number.isInteger(reading.page) ? reading.page : 0);
else throw new Error("Empty presentation");
fitWidth();
}
if (Number.isFinite(reading.zoom)) {
if (reading.fitting) fitWidth();
else setZoom(reading.zoom);
}
if (Array.isArray(reading.scroll) && reading.scroll.every(Number.isFinite))
canvas.scrollTo(reading.scroll[0], reading.scroll[1]);
ready = true;
query.disabled = false;
save();
const observer = new ResizeObserver(() => {
if (fitting && !context.signal.aborted) fitWidth();
});
observer.observe(canvas);
disposals.push(() => observer.disconnect());
if (context.signal.aborted) {
root.replaceChildren();
return () => {};
}
return () => {
for (const dispose of disposals) dispose();
root.replaceChildren();
};
}

View File

@ -0,0 +1,209 @@
import JSZip from "jszip";
import {
LIMITS,
preflightZip,
inspectXml,
imagePixels,
} from "./resource-limits.js";
function inflateEntry(entry, record, budget) {
return new Promise((resolve, reject) => {
const chunks = [];
let length = 0,
stopped = false;
const stream = entry.internalStream("uint8array");
stream.on("data", (chunk) => {
if (stopped) return;
length += chunk.length;
budget.bytes += chunk.length;
if (
length > record.uncompressed ||
length > LIMITS.entryBytes ||
budget.bytes > LIMITS.expandedBytes
) {
stopped = true;
stream.pause();
reject(new Error("Actual Office expansion exceeds preview limit"));
} else chunks.push(chunk);
});
stream.on("error", reject);
stream.on("end", () => {
if (stopped) return;
if (length !== record.uncompressed) {
reject(new Error("Invalid Office archive expanded size"));
return;
}
const result = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
resolve(result);
});
stream.resume();
});
}
/** Called only by a dedicated worker; exported for offline adversarial tests. */
export async function prepareDocument(bytes, kind, onPhase = () => {}) {
if (!["xlsx", "docx", "pptx"].includes(kind))
throw new Error("Unsupported Office preview format");
const records = preflightZip(bytes);
const xmlRecords = records.filter((record) =>
/\.(xml|rels|vml)$/i.test(record.name),
);
if (xmlRecords.some((record) => record.uncompressed > LIMITS.xmlBytes))
throw new Error("Office XML size limit exceeded");
if (
records.filter((record) => /^ppt\/slides\/slide\d+\.xml$/.test(record.name))
.length > LIMITS.slides
)
throw new Error("Office slide limit exceeded");
const zip = await JSZip.loadAsync(bytes, { createFolders: false });
if (
Object.keys(zip.files).length !== records.length ||
records.some((record) => !Object.hasOwn(zip.files, record.name))
)
throw new Error("Inconsistent Office ZIP names");
const budget = { bytes: 0, pixels: 0, elements: 0, text: 0, units: 0 };
// Serial inflation avoids allocating every uncompressed member concurrently.
for (const record of records) {
if (record.name.endsWith("/")) continue;
const data = await inflateEntry(zip.files[record.name], record, budget);
const isXml = /\.(xml|rels|vml)$/i.test(record.name);
if (isXml) {
const xml = new TextDecoder("utf-8", { fatal: true }).decode(data);
// docx-preview ignores this alternate style tree. Still validate it under
// worker XML budgets, without charging it twice to the DOM layout budget.
inspectXml(
xml,
kind,
budget,
record.name !== "word/stylesWithEffects.xml",
);
}
if (!isXml) {
// Font/object parts cannot be used to smuggle an unchecked image to the UI.
if (
/\/(embeddings|printerSettings)\//i.test(record.name) ||
/\.(odttf|ttf|otf|woff2?)$/i.test(record.name)
) {
zip.remove(record.name);
continue;
}
if (kind !== "xlsx") {
const pixels = imagePixels(data);
budget.pixelsBytes = (budget.pixelsBytes ?? 0) + data.byteLength;
budget.pixels += pixels;
if (
!pixels ||
pixels > LIMITS.imagePixels ||
budget.pixels > LIMITS.totalImagePixels
)
throw new Error("Office image pixel limit exceeded");
}
}
// Cache validated uncompressed bytes; later parsers cannot inflate unchecked data.
zip.file(record.name, data, { createFolders: false });
}
onPhase(kind);
if (kind === "xlsx") return { kind, sheets: await prepareWorkbook(zip) };
if (budget.bytes > LIMITS.preparedBytes)
throw new Error("Office render size limit exceeded");
const slides = records.filter((record) =>
/^ppt\/slides\/slide\d+\.xml$/.test(record.name),
).length;
if (
kind === "pptx" &&
((budget.pixelsBytes ?? 0) *
Math.max(1, slides) *
Math.max(1, budget.imageReferences ?? 0) *
4) /
3 >
LIMITS.preparedBytes
)
throw new Error("Office image reuse size limit exceeded");
const { prepareWord, preparePresentation } =
await import("./worker-documents.js");
return kind === "docx" ? prepareWord(zip) : preparePresentation(zip);
}
async function prepareWorkbook(zip) {
const { default: ExcelJS } = await import("exceljs");
// Cell-only preview: discard drawing/media parts in our copy, keeping originals intact.
zip.remove("xl/drawings");
zip.remove("xl/media");
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(
await zip.generateAsync({ type: "arraybuffer", compression: "STORE" }),
{
ignoreNodes: ["drawing", "picture", "extLst", "tableParts"],
},
);
const sheets = workbook.worksheets.filter(
(sheet) => sheet.state === "visible",
);
if (sheets.length > LIMITS.sheets)
throw new Error("Office worksheet limit exceeded");
let cells = 0,
characters = 0;
const color = (argb) =>
/^[A-Fa-f0-9]{8}$/.test(argb ?? "") ? argb : undefined;
return sheets.map((sheet) => {
const rows = Math.min(sheet.rowCount, 500),
cols = Math.min(sheet.columnCount, 50);
cells += rows * cols;
if (cells > LIMITS.cells)
throw new Error("Office preview cell limit exceeded");
return {
id: sheet.id,
name: sheet.name,
rowCount: sheet.rowCount,
columnCount: sheet.columnCount,
columns: Array.from({ length: cols }, (_, index) => {
const column = sheet.getColumn(index + 1);
return { hidden: !!column.hidden, width: column.width };
}),
merges: Object.values(sheet._merges).map((merge) => merge.model),
rows: Array.from({ length: rows }, (_, index) => {
const row = sheet.getRow(index + 1);
return {
hidden: !!row.hidden,
cells: Array.from({ length: cols }, (_, c) => {
const cell = row.getCell(c + 1),
text = cell.text,
formula = cell.formula;
characters += text.length + (formula?.length ?? 0);
if (
characters > LIMITS.textCharacters ||
text.length > 8192 ||
(formula?.length ?? 0) > 8192
)
throw new Error("Office preview cell text limit exceeded");
return {
address: cell.address,
text,
formula,
isMerged: cell.isMerged,
master: { address: cell.master.address },
font: {
bold: !!cell.font?.bold,
italic: !!cell.font?.italic,
color: { argb: color(cell.font?.color?.argb) },
},
fill: { fgColor: { argb: color(cell.fill?.fgColor?.argb) } },
alignment: {
horizontal: ["left", "center", "right"].includes(
cell.alignment?.horizontal,
)
? cell.alignment.horizontal
: undefined,
},
};
}),
};
}),
};
});
}

View File

@ -0,0 +1,322 @@
import { SaxesParser } from "saxes";
// Preview budgets, not Office format limits. Originals remain downloadable.
export const LIMITS = Object.freeze({
inputBytes: 25 * 1024 * 1024,
entries: 4000,
expandedBytes: 100 * 1024 * 1024,
entryBytes: 16 * 1024 * 1024,
ratio: 200,
xmlBytes: 2 * 1024 * 1024,
xmlElements: 100000,
xmlDepth: 64,
renderElements: 20000,
renderUnits: 2000,
textCharacters: 2 * 1024 * 1024,
slides: 50,
sheets: 20,
cells: 100000,
imagePixels: 16 * 1024 * 1024,
totalImagePixels: 32 * 1024 * 1024,
preparedBytes: 32 * 1024 * 1024,
});
/** Read bounded central/local metadata before JSZip allocates its object graph. */
export function preflightZip(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.inputBytes)
throw new Error("Office archive exceeds preview size limit");
const view = new DataView(buffer),
length = buffer.byteLength;
const u16 = (p) => view.getUint16(p, true);
const u32 = (p) => view.getUint32(p, true);
let end = -1;
for (let p = length - 22; p >= Math.max(0, length - 65557); p--)
if (u32(p) === 0x06054b50 && p + 22 + u16(p + 20) === length) {
end = p;
break;
}
if (end < 0) throw new Error("Invalid ZIP archive directory");
const count = u16(end + 10),
size = u32(end + 12),
start = u32(end + 16);
if (
u16(end + 4) ||
u16(end + 6) ||
u16(end + 8) !== count ||
count === 0xffff ||
size === 0xffffffff ||
start === 0xffffffff
)
throw new Error("Unsupported split or ZIP64 archive");
if (count > LIMITS.entries)
throw new Error("ZIP archive member limit exceeded");
if (start + size !== end) throw new Error("Invalid ZIP archive bounds");
const records = [],
names = new Set(),
ranges = [];
const decoder = new TextDecoder("utf-8", { fatal: true });
let cursor = start,
expanded = 0;
for (let i = 0; i < count; i++) {
if (cursor + 46 > end || u32(cursor) !== 0x02014b50)
throw new Error("Invalid ZIP archive entry");
const flags = u16(cursor + 8),
method = u16(cursor + 10);
const compressed = u32(cursor + 20),
uncompressed = u32(cursor + 24);
const nameSize = u16(cursor + 28),
extraSize = u16(cursor + 30);
const next = cursor + 46 + nameSize + extraSize + u16(cursor + 32);
const local = u32(cursor + 42);
if (
next > end ||
local + 30 > start ||
u32(local) !== 0x04034b50 ||
u16(cursor + 34) ||
flags & ~0x080e ||
flags & 1 ||
![0, 8].includes(method) ||
compressed === 0xffffffff ||
uncompressed === 0xffffffff
)
throw new Error("Unsupported or invalid ZIP archive entry");
expanded += uncompressed;
if (uncompressed > LIMITS.entryBytes || expanded > LIMITS.expandedBytes)
throw new Error("Expanded Office archive too large");
if (uncompressed > Math.max(1, compressed) * LIMITS.ratio)
throw new Error("ZIP archive compression ratio limit exceeded");
const name = decoder.decode(new Uint8Array(buffer, cursor + 46, nameSize));
if (
!name ||
name.length > 1024 ||
name.split("/").length > 32 ||
names.has(name) ||
name.includes("\\") ||
name.includes("\0") ||
name.startsWith("/") ||
name.split("/").some((p) => p === ".." || p === ".")
)
throw new Error("Invalid or duplicate ZIP archive name");
names.add(name);
for (
let p = cursor + 46 + nameSize;
p < cursor + 46 + nameSize + extraSize;
) {
if (p + 4 > next || u16(p) === 1)
throw new Error("Unsupported ZIP64 archive");
const extraEnd = p + 4 + u16(p + 2);
if (extraEnd > cursor + 46 + nameSize + extraSize)
throw new Error("Invalid ZIP archive extra data");
p = extraEnd;
}
const data = local + 30 + u16(local + 26) + u16(local + 28);
if (
u16(local + 6) !== flags ||
u16(local + 8) !== method ||
data + compressed > start ||
decoder.decode(new Uint8Array(buffer, local + 30, u16(local + 26))) !==
name ||
(!(flags & 8) &&
(u32(local + 18) !== compressed || u32(local + 22) !== uncompressed))
)
throw new Error("Mismatched ZIP archive local entry");
ranges.push([local, data + compressed]);
records.push({
name,
compressed,
uncompressed,
directoryOffset: cursor,
localOffset: local,
});
cursor = next;
}
ranges.sort((a, b) => a[0] - b[0]);
if (
cursor !== end ||
ranges.some((range, i) => i && range[0] < ranges[i - 1][1])
)
throw new Error("Overlapping or inconsistent ZIP archive entries");
return records;
}
/** Streaming XML validation runs in the terminable worker, never the UI thread. */
export function inspectXml(
xml,
kind,
budget = { elements: 0, text: 0, units: 0 },
renderable = true,
) {
const parser = new SaxesParser({ xmlns: true });
let depth = 0,
tables = 0,
styleId;
const styleBases = new Map();
const fail = () => {
throw new Error("Office XML complexity limit exceeded");
};
parser.on("doctype", () => {
throw new Error("Office XML DOCTYPE is unsupported");
});
parser.on("opentag", (tag) => {
depth++;
budget.elements++;
if (["blip", "imagedata"].includes(tag.local))
budget.imageReferences = (budget.imageReferences ?? 0) + 1;
if (renderable) budget.renderElements = (budget.renderElements ?? 0) + 1;
if (
renderable &&
["p", "tr", "br", "sectPr", "sp", "pic", "graphicFrame"].includes(
tag.local,
)
)
budget.units++;
if (tag.local === "tbl" && ++tables > 4) fail();
if (
depth > LIMITS.xmlDepth ||
budget.elements > LIMITS.xmlElements ||
Object.keys(tag.attributes).length > 64 ||
(kind !== "xlsx" &&
((budget.renderElements ?? 0) > LIMITS.renderElements ||
budget.units > LIMITS.renderUnits))
)
fail();
// Huge table spans/column counts can amplify small XML into large DOM trees.
for (const attr of Object.values(tag.attributes)) {
if (attr.value.length > 8192) fail();
if (
kind === "pptx" &&
((attr.local === "idx" && Number(attr.value) > 10000) ||
(tag.local === "ptCount" &&
attr.local === "val" &&
Number(attr.value) > 10000))
)
fail();
if (
kind === "xlsx" &&
tag.local === "row" &&
attr.local === "r" &&
Number(attr.value) > 10000
)
fail();
if (kind === "xlsx" && tag.local === "c" && attr.local === "r") {
const coordinate = /^([A-Z]{1,3})([1-9]\d*)$/.exec(attr.value);
if (
!coordinate ||
Number(coordinate[2]) > 10000 ||
[...coordinate[1]].reduce(
(n, c) => n * 26 + c.charCodeAt(0) - 64,
0,
) > 1000
)
fail();
}
if (
["cx", "cy"].includes(attr.local) &&
Math.abs(Number(attr.value)) > 40000000
)
fail();
if (
tag.local === "pgSz" &&
["w", "h"].includes(attr.local) &&
Number(attr.value) > 31680
)
fail();
if (
["sz", "szCs"].includes(tag.local) &&
attr.local === "val" &&
Number(attr.value) > 1024
)
fail();
if (
["rPr", "defRPr", "endParaRPr"].includes(tag.local) &&
attr.local === "sz" &&
Number(attr.value) > 51200
)
fail();
if (
["gridSpan", "vMerge"].includes(tag.local) &&
attr.local === "val" &&
Number(attr.value) > 100
)
fail();
if (
kind === "xlsx" &&
tag.local === "mergeCell" &&
attr.local === "ref"
) {
const range = /^([A-Z]{1,3})([1-9]\d*):([A-Z]{1,3})([1-9]\d*)$/.exec(
attr.value,
);
if (!range) fail();
const column = (value) =>
[...value].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0);
const width = column(range[3]) - column(range[1]) + 1;
const height = Number(range[4]) - Number(range[2]) + 1;
budget.mergeCells = (budget.mergeCells ?? 0) + width * height;
if (width < 1 || height < 1 || budget.mergeCells > LIMITS.cells) fail();
}
if (tag.local === "style" && attr.local === "styleId")
styleId = attr.value;
if (tag.local === "basedOn" && attr.local === "val" && styleId)
styleBases.set(styleId, attr.value);
}
});
parser.on("closetag", (tag) => {
depth--;
if (tag.local === "tbl") tables--;
if (tag.local === "style") styleId = undefined;
});
const text = (value) => {
budget.text += value.length;
if (budget.text > LIMITS.textCharacters) fail();
};
parser.on("text", text);
parser.on("cdata", text);
// Bound individual parser.write calls as well as cumulative document size.
for (let offset = 0; offset < xml.length; offset += 16384)
parser.write(xml.slice(offset, offset + 16384));
parser.close();
// Word's inherited styles are recursively resolved by DOM renderers.
const complete = new Set();
for (const id of styleBases.keys()) {
const path = new Set();
let current = id;
while (current && !complete.has(current)) {
if (path.has(current) || path.size >= 64) fail();
path.add(current);
current = styleBases.get(current);
}
for (const entry of path) complete.add(entry);
}
return budget;
}
/** Check decoded raster dimensions before any browser image decoder sees them. */
export function imagePixels(data) {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (data.length >= 24 && view.getUint32(0) === 0x89504e47) {
for (let offset = 8; offset + 12 <= data.length; ) {
const length = view.getUint32(offset);
if (view.getUint32(offset + 4) === 0x6163544c)
throw new Error("Animated Office preview images are unsupported");
if (length > data.length - offset - 12) break;
offset += length + 12;
}
return view.getUint32(16) * view.getUint32(20);
}
if (data.length >= 4 && view.getUint16(0) === 0xffd8) {
for (let p = 2; p + 4 <= data.length; ) {
if (data[p++] !== 0xff) break;
while (data[p] === 0xff) p++;
const marker = data[p++];
if (marker === 0xd9 || marker === 0xda) break;
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
const length = view.getUint16(p);
if (length < 2 || p + length > data.length) break;
if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7)
return view.getUint16(p + 3) * view.getUint16(p + 5);
p += length;
}
}
throw new Error("Unsupported Office preview image format");
}

View File

@ -0,0 +1,140 @@
import { createUniqueIdAllocator } from "./identifiers.js";
/** Literal matches across styled runs, with no HTML insertion. */
export const MAX_SEARCH_MATCHES = 2000;
export function highlightText(root, query, limit = MAX_SEARCH_MATCHES) {
root
.querySelectorAll("mark[data-office-match]")
.forEach((mark) => mark.replaceWith(...mark.childNodes));
root.normalize();
if (!query.trim() || limit <= 0) return [];
const groups = [];
const blocks = [
...root.querySelectorAll("p,td,th,li,h1,h2,h3,h4,h5,h6"),
].filter((node) => !node.querySelector("p,td,th,li,h1,h2,h3,h4,h5,h6"));
if (!blocks.length) blocks.push(root);
const pattern = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
for (const block of blocks) {
if (groups.length >= limit) break;
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
acceptNode: (node) =>
node.parentElement.closest("style,script")
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
});
const nodes = [];
let text = "",
node;
while ((node = walker.nextNode())) {
nodes.push({ node, start: text.length });
text += node.textContent;
}
const matches = [];
for (const match of text.matchAll(new RegExp(pattern, "giu"))) {
matches.push(match);
if (matches.length >= limit - groups.length) break;
}
const local = matches.map(() => []);
for (const { node: textNode, start } of nodes) {
const content = textNode.textContent,
fragment = document.createDocumentFragment();
let cursor = 0;
matches.forEach((match, index) => {
const from = Math.max(0, match.index - start),
to = Math.min(content.length, match.index + match[0].length - start);
if (from >= to) return;
fragment.append(document.createTextNode(content.slice(cursor, from)));
const mark = document.createElement("mark");
mark.dataset.officeMatch = "";
mark.textContent = content.slice(from, to);
fragment.append(mark);
local[index].push(mark);
cursor = to;
});
if (cursor) {
fragment.append(document.createTextNode(content.slice(cursor)));
textNode.replaceWith(fragment);
}
}
groups.push(...local.filter((group) => group.length));
}
return groups;
}
/** Add private bookmarks only to the preview copy, so outline targets survive page layout. */
export async function prepareWordOutline(zip) {
const parse = (xml) =>
new DOMParser().parseFromString(xml, "application/xml");
const attr = (node, name) =>
node?.getAttributeNS(
"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
name,
);
const child = (node, name) =>
[...(node?.children ?? [])].find((entry) => entry.localName === name);
const styles = new Map();
const source = zip.file("word/styles.xml");
if (source)
for (const style of parse(
await source.async("string"),
).getElementsByTagNameNS("*", "style")) {
styles.set(attr(style, "styleId"), {
level: attr(child(child(style, "pPr"), "outlineLvl"), "val"),
base: attr(child(style, "basedOn"), "val"),
});
}
const resolveLevel = (id, visited = new Set()) => {
if (!id || visited.has(id)) return undefined;
visited.add(id);
const style = styles.get(id);
return style?.level ?? resolveLevel(style?.base, visited);
};
const file = zip.file("word/document.xml");
if (!file) return [];
const xml = parse(await file.async("string")),
outline = [];
const bookmarks = [...xml.getElementsByTagNameNS("*", "bookmarkStart")];
const nextName = createUniqueIdAllocator(
bookmarks.map((bookmark) => attr(bookmark, "name") ?? ""),
"office-",
);
const nextId = createUniqueIdAllocator(
[...bookmarks, ...xml.getElementsByTagNameNS("*", "bookmarkEnd")].map(
(bookmark) => attr(bookmark, "id") ?? "",
),
"",
100000,
);
for (const paragraph of xml.getElementsByTagNameNS("*", "p")) {
const props = child(paragraph, "pPr");
const level = Number(
attr(child(props, "outlineLvl"), "val") ??
resolveLevel(attr(child(props, "pStyle"), "val")) ??
9,
);
const title = [...paragraph.getElementsByTagNameNS("*", "t")]
.map((node) => node.textContent)
.join("")
.trim();
if (level < 0 || level > 8 || !title) continue;
const id = nextName();
const bookmarkId = nextId();
const bookmark = xml.createElementNS(
paragraph.namespaceURI,
"w:bookmarkStart",
);
bookmark.setAttributeNS(paragraph.namespaceURI, "w:name", id);
bookmark.setAttributeNS(paragraph.namespaceURI, "w:id", bookmarkId);
paragraph.insertBefore(
bookmark,
props?.nextSibling ?? paragraph.firstChild,
);
const end = xml.createElementNS(paragraph.namespaceURI, "w:bookmarkEnd");
end.setAttributeNS(paragraph.namespaceURI, "w:id", bookmarkId);
paragraph.appendChild(end);
outline.push({ id, title, level });
}
zip.file("word/document.xml", new XMLSerializer().serializeToString(xml));
return outline;
}

View File

@ -0,0 +1,54 @@
import { resolvePartPath } from "./document-model.js";
/** Minimal resource/relationship facade for docx-preview's public renderDocument API. */
export function wordView(prepared) {
const urls = new Map();
let disposed = false;
const resource = (part, id) => {
if (disposed) return null;
const rel = part?.rels?.find(
(entry) => entry.id === id && entry.targetMode !== "External",
);
if (!rel) return null;
const path = resolvePartPath(part.path, rel.target);
if (urls.has(path)) return urls.get(path);
const image = Object.hasOwn(prepared.images, path)
? prepared.images[path]
: null;
if (!image) return null;
const type = image[0] === 0x89 ? "image/png" : "image/jpeg";
const url = URL.createObjectURL(new Blob([image], { type }));
urls.set(path, url);
return url;
};
const word = prepared.word;
return {
document: {
...word,
findPartByRelId(id, base = null) {
const rel = (base?.rels ?? word.rels).find(
(entry) => entry.id === id && entry.targetMode !== "External",
);
const path = rel ? resolvePartPath(base?.path ?? "", rel.target) : "";
return Object.hasOwn(word.partsMap, path) ? word.partsMap[path] : null;
},
async loadDocumentImage(id, part) {
return resource(part ?? word.documentPart, id);
},
async loadNumberingImage(id) {
return resource(word.numberingPart, id);
},
async loadFont() {
return null;
},
async loadAltChunk() {
return null;
},
},
dispose() {
disposed = true;
for (const url of urls.values()) URL.revokeObjectURL(url);
urls.clear();
},
};
}

View File

@ -0,0 +1,15 @@
/** Rehydrate bounded plain preview data, without importing ExcelJS on the UI thread. */
export function workbookViews(sheets) {
return sheets.map((sheet) => ({
...sheet,
_merges: Object.fromEntries(
sheet.merges.map((model) => [`${model.top}:${model.left}`, { model }]),
),
getColumn: (c) => sheet.columns[c - 1] ?? {},
getRow: (r) => ({
...sheet.rows[r - 1],
getCell: (c) => sheet.rows[r - 1].cells[c - 1],
}),
getCell: (r, c) => sheet.rows[r - 1].cells[c - 1],
}));
}

View File

@ -0,0 +1,63 @@
/** Each request owns one disposable worker; no stale result or parser survives it. */
export async function prepareOffice(
bytes,
kind,
signal,
{
timeoutMs = 15000,
workerFactory = () =>
new Worker(new URL("./office-worker.mjs", import.meta.url), {
type: "module",
}),
} = {},
) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
let worker,
timer,
settled = false;
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal.removeEventListener("abort", abort);
if (worker) {
worker.onmessage = worker.onerror = worker.onmessageerror = null;
worker.terminate();
}
if (error) reject(error);
else resolve(result);
};
const abort = () =>
finish(
signal.reason ?? new DOMException("Preview cancelled", "AbortError"),
);
try {
worker = workerFactory();
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) {
abort();
return;
}
timer = setTimeout(
() => finish(new Error("Office preview parsing timed out")),
timeoutMs,
);
worker.onmessage = ({ data }) => {
if (data.error) finish(new Error(data.error));
else if (Object.hasOwn(data, "result")) finish(null, data.result);
};
worker.onerror = (event) => {
event.preventDefault();
finish(new Error("Office preview worker failed"));
};
worker.onmessageerror = () =>
finish(new Error("Office preview worker response failed"));
// Keep the host's original read buffer intact; transfer only our private copy.
const input = bytes.slice(0);
worker.postMessage({ bytes: input, kind }, [input]);
} catch (error) {
finish(error);
}
});
}

View File

@ -0,0 +1,110 @@
import { WorkerXmlParser, installWorkerXml } from "./worker-xml.js";
import { prepareWordOutline } from "./search.js";
import { resolvePartPath, checkModelBudget } from "./document-model.js";
/** Strip parser/ZIP instances and parent links; the renderer reconstructs parents. */
function plainModel(value, seen = new Map()) {
if (!value || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value);
const result = Array.isArray(value) ? [] : Object.create(null);
seen.set(value, result);
for (const [key, child] of Object.entries(value)) {
if (key.startsWith("_") || key === "parent") continue;
if (typeof child === "function" || child?.nodeType)
throw new Error("Unsupported Word model value");
result[key] = plainModel(child, seen);
}
return result;
}
export async function prepareWord(zip) {
installWorkerXml();
const headings = await prepareWordOutline(zip);
const { parseAsync } = await import("docx-preview");
const document = await parseAsync(
await zip.generateAsync({ type: "arraybuffer", compression: "STORE" }),
{
ignoreFonts: true,
renderAltChunks: false,
useBase64URL: false,
},
);
const selected = { rels: document.rels, partsMap: document.partsMap };
for (const [name, part] of Object.entries(document))
if (name.endsWith("Part")) selected[name] = part;
const word = plainModel(selected);
const images = {};
for (const part of document.parts)
for (const rel of part.rels ?? []) {
if (!rel.type.endsWith("/image") || rel.targetMode === "External")
continue;
const path = resolvePartPath(part.path, rel.target);
const entry = zip.file(path);
if (entry && !Object.hasOwn(images, path))
images[path] = await entry.async("uint8array");
}
const result = { kind: "docx", word, headings, images };
checkModelBudget(result);
return result;
}
export async function preparePresentation(zip) {
installWorkerXml();
// The converter omits paragraph defaults. Normalize only the worker's private ZIP.
for (const entry of Object.values(zip.files).filter((entry) =>
/^ppt\/slides\/slide\d+\.xml$/.test(entry.name),
)) {
const xml = new WorkerXmlParser().parseFromString(
await entry.async("string"),
"application/xml",
);
let changed = false;
for (const paragraph of xml.getElementsByTagNameNS("*", "p")) {
const defaults = [...paragraph.children]
.find((node) => node.localName === "pPr")
?.getElementsByTagNameNS("*", "defRPr")[0];
if (!defaults) continue;
for (const run of [...paragraph.children].filter(
(node) => node.localName === "r",
)) {
let properties = [...run.children].find(
(node) => node.localName === "rPr",
);
if (!properties) {
properties = xml.createElementNS(defaults.namespaceURI, "a:rPr");
run.insertBefore(properties, run.firstChild);
}
for (const attr of defaults.attributes)
if (!properties.hasAttribute(attr.name))
properties.setAttribute(attr.name, attr.value);
for (const child of defaults.children)
if (
![...properties.children].some(
(node) => node.localName === child.localName,
)
)
properties.appendChild(child.cloneNode(true));
changed = true;
}
}
if (changed)
zip.file(entry.name, new XMLSerializer().serializeToString(xml), {
createFolders: false,
});
}
const { pptxToHtml } = await import("@jvmr/pptx-to-html");
const slides = await pptxToHtml(
await zip.generateAsync({ type: "arraybuffer", compression: "STORE" }),
{
width: 960,
height: 540,
scaleToFit: true,
domParserFactory: () => new WorkerXmlParser(),
},
);
if (slides.some((slide) => slide.length > 512 * 1024))
throw new Error("Office slide HTML limit exceeded");
const result = { kind: "pptx", slides };
checkModelBudget(result);
return result;
}

View File

@ -0,0 +1,92 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
// Only the namespace/tag descendant selectors used by the pinned PPT converter.
// Unknown selector syntax fails explicitly rather than returning incorrect output.
function selectors(value) {
return value.split(",").map((group) =>
group
.trim()
.split(/\s+/)
.map((token) => {
const name = token.replaceAll("\\:", ":");
if (!/^(?:\*\|)?[A-Za-z_][\w.:-]*$/.test(name))
throw new Error("Unsupported Office XML selector");
return name;
}),
);
}
function matches(node, name) {
return name.startsWith("*|")
? node.localName === name.slice(2)
: node.tagName === name;
}
function queryAll(root, selector) {
const groups = selectors(selector),
found = [];
for (const node of root.getElementsByTagName("*")) {
if (
groups.some((group) => {
if (!matches(node, group.at(-1))) return false;
let parent = node.parentElement;
for (let i = group.length - 2; i >= 0; i--) {
while (
parent &&
parent !== root.parentElement &&
!matches(parent, group[i])
)
parent = parent.parentElement;
if (!parent || parent === root.parentElement) return false;
parent = parent.parentElement;
}
return true;
})
)
found.push(node);
}
return found;
}
export class WorkerXmlParser extends DOMParser {
constructor() {
super({
onError: () => {
throw new Error("Invalid Office XML");
},
});
}
parseFromString(xml, type) {
const doc = super.parseFromString(xml, type);
for (const prototype of [
Object.getPrototypeOf(doc),
Object.getPrototypeOf(doc.documentElement),
]) {
if (!Object.getOwnPropertyDescriptor(prototype, "firstElementChild"))
Object.defineProperty(prototype, "firstElementChild", {
get() {
return (
[...this.childNodes].find((node) => node.nodeType === 1) ?? null
);
},
});
if (!prototype.querySelectorAll)
Object.defineProperties(prototype, {
querySelectorAll: {
value(selector) {
return queryAll(this, selector);
},
},
querySelector: {
value(selector) {
return queryAll(this, selector)[0] ?? null;
},
},
});
}
return doc;
}
}
export function installWorkerXml() {
// Never provide a fake browser Window or document. Rendering stays in the UI.
globalThis.DOMParser = WorkerXmlParser;
globalThis.XMLSerializer = XMLSerializer;
globalThis.Node = { ELEMENT_NODE: 1 };
}

View File

@ -68,6 +68,10 @@ through `scripts/pnpm.py`.
- **`make build-static` produces a read-only demo.** Gateway reads come from
bundled fixtures, and writes fail locally.
File attachments use typed session errors, preserving per-file causes independently
of display text. Preview and upload ownership, Office worker budgets and resource
regressions: [file preview ownership](docs/file-preview-ownership.md).
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know

View File

@ -137,6 +137,13 @@ Build from `src/components/ui/` (shadcn on Radix) before writing new primitives:
## Known exceptions
File-format identity tiles in `workspace/file-type-icon.tsx` use four paired
light/dark tints in `styles/globals.css`: green for spreadsheets, blue for Word,
orange for slides, and purple for JSON. Icons and type captions also identify the
format, so color is supplementary. These tints apply only to file identity tiles;
surrounding controls use semantic tokens. Rendered document colors belong to the
file content and preserve the author's styling.
Landing-only decoration that does not follow the rules above. Don't copy these
into app or docs UI:

View File

@ -28,3 +28,11 @@ This expands the ownership index in `frontend/src/AGENTS.md`.
frame, publishes through `useSyncExternalStore` at most once per animation
frame, and owns object-URL revocation. Keep the Gateway's legacy JSON/base64
frame path for older clients.
# Sidebar customization
`components/workspace/workspace-sidebar-frame.tsx`: rail/drawer mount one tree;
`useWorkspaceSidebarExpanded` preserves desktop `SidebarProvider.open`. Keep
polling/delete-dialog provider outside that tree. Reuse stock `Sidebar`; close
before link navigation and on pathname changes, preserving page focus. Dismissal
restores the opener or remounted rail trigger.

View File

@ -0,0 +1,47 @@
# File attachments and preview contributions
`workspace/file-type-icon.tsx` and `file-name.tsx` share file identity across selected
uploads, sent attachments and returned artifacts. Keep custom attachment controls
in `workspace/`; generated AI Elements stay unchanged.
`core/uploads/attachment-upload-session.ts` owns per-file state and successful
receipts for one composer, viewer and thread. Retry is upload-only; failed or
cancelled files preserve the draft and prevent sending until resolved or removed.
Keep actionable errors with each failed file. `AttachmentUploadError` carries a
session code and every failed part's original cause; localize session states by
type/code, never by matching diagnostic text. `ArtifactDownloadButton` owns bounded
preparation, duplicate-click prevention, failure feedback and stale-callback fencing.
Browser handoff does not prove a disk write.
Optional `artifactPreviews` contributions use exact lowercase extensions and remain
fenced to enabled registrations for the current viewer. `PluginArtifactPreview` owns
abort/dispose and shadow-root styling; authenticated reads are capped at 25 MiB.
Reload an open preview when the thread run settles. Reading state is bounded JSON in
memory, keyed by viewer, thread, file, renderer and file version (Gateway ETag, or
optional content hash). If neither is available, preview still works without reading
state restoration. Account changes clear it; obsolete mounts cannot write. Built-in
text and media readers retain precedence. The standalone page always owns auth/query
providers, including public Showcase targets; public access is unknown auth, not a
confirmed logout. Office preview works on HTTP LAN origins without Web Crypto.
The Office extension owns a worker per mount. Disposal and the host abort signal
terminate it; a 15-second deadline also terminates stalled preparation. ZIP central
and local records are checked before JSZip, followed by sequential inflation with
actual byte limits, streaming XML complexity checks and raster dimension checks.
ExcelJS, Word XML/model preparation and PPT conversion stay in the worker; the UI
never reopens an Office archive or parses its XML. Word uses docx-preview's public
parseAsync/renderDocument split; a package-local resource facade resolves headers,
footers and validated images, deduplicates Blob URLs and revokes them on abort/dispose.
The pinned XML adapter implements only the namespace/tag selectors the converter
uses and fails explicitly on unsupported selector syntax. Parser output is checked
before structured clone (32 MiB / 100,000 model objects, 512 KiB per PPT slide).
Table rows yield between batches; revision checks fence overlapping sheet changes.
Word's remaining native DOM construction has bounded complexity and no hard timeout;
embedded fonts and alt chunks stay disabled. Worker conversion phases are metadata
only and must never settle a pending request; tests freeze each conversion phase
and verify actual termination on cancellation/deadline.
Build the worker as a self-contained module: Gateway asset CSP sandbox creates an
opaque worker origin, so further authenticated module fetches are unavailable.
`office-preview-resources.spec.ts` exercises real packaged assets with that CSP,
samples process-tree RSS/UI heap, records long tasks/heartbeat gaps, and tests
adversarial XML, stalled conversion phases, cancellation, no main-thread XML parsing,
successful rendering and image/header/footer preservation with Blob URL cleanup.

View File

@ -1,7 +1,6 @@
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
import { QueryClientProvider } from "@/components/query-client-provider";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
@ -17,16 +16,13 @@ export const dynamic = "force-dynamic";
* The auth guard lives in the page rather than here. A layout cannot read
* `searchParams`, and this route carries its whole target in the query string
* — guarding here would redirect to /login with nothing to come back to.
* No AuthProvider either: nothing under this route reads `useAuth`.
* The page also owns the auth/query providers: installed preview contributions
* need the current viewer, while public showcase readers stay anonymous.
*/
export default async function ArtifactViewerLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await detectLocaleServer();
return (
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
);
return <I18nProvider initialLocale={locale}>{children}</I18nProvider>;
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { QueryClientProvider } from "@/components/query-client-provider";
import { ArtifactViewer } from "@/components/workspace/artifacts/artifact-viewer";
import {
artifactViewerTitle,
@ -9,6 +10,7 @@ import {
requiresAuthenticatedViewer,
type ArtifactViewerTarget,
} from "@/core/artifacts/viewer";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import {
ACCOUNT_DISABLED_LOGIN_URL,
@ -40,12 +42,14 @@ export async function generateMetadata({
*/
async function requireViewerAccess(target: ArtifactViewerTarget | null) {
if (target && !requiresAuthenticatedViewer(target)) {
return;
// Public access is not a confirmed logout. Keep the same provider boundary
// as private viewers without probing auth or clearing retained drafts.
return { user: null, unknown: true };
}
const result = await getServerSideUser();
switch (result.tag) {
case "authenticated":
return;
return { user: result.user, unknown: false };
case "unauthenticated":
redirect(
buildLoginUrl(
@ -64,7 +68,7 @@ async function requireViewerAccess(target: ArtifactViewerTarget | null) {
// Render anyway: the viewer surfaces its own load failure with a
// download link, which beats bouncing a detached window to a page the
// user did not ask for.
return;
return { user: null, unknown: true };
default:
assertNever(result);
}
@ -74,7 +78,7 @@ export default async function ArtifactViewerPage({
searchParams,
}: ArtifactViewerPageProps) {
const target = parseArtifactViewerQuery(await searchParams);
await requireViewerAccess(target);
const auth = await requireViewerAccess(target);
if (!target) {
const { t } = await getI18n();
@ -88,10 +92,14 @@ export default async function ArtifactViewerPage({
}
return (
<ArtifactViewer
filepath={target.filepath}
threadId={target.threadId}
isMock={target.isMock}
/>
<AuthProvider initialUser={auth.user} initialAuthUnknown={auth.unknown}>
<QueryClientProvider>
<ArtifactViewer
filepath={target.filepath}
threadId={target.threadId}
isMock={target.isMock}
/>
</QueryClientProvider>
</AuthProvider>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import {
CheckIcon,
DownloadIcon,
LoaderIcon,
RotateCwIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { prepareArtifactDownload } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { getFileName } from "@/core/utils/files";
type Props = {
filepath: string;
threadId: string;
isMock?: boolean;
compact?: boolean;
label?: string;
};
function DownloadAction({ filepath, threadId, isMock, compact, label }: Props) {
const { t } = useI18n();
const [state, setState] = useState<
"idle" | "preparing" | "failed" | "requested"
>("idle");
const request = useRef<AbortController | null>(null);
const urls = useRef(new Set<string>());
useEffect(
() => () => {
request.current?.abort();
urls.current.forEach((url) => URL.revokeObjectURL(url));
},
[],
);
const filename = getFileName(filepath);
const text =
state === "preparing"
? t.artifactPreview.preparingDownload
: state === "failed"
? t.artifactPreview.retryDownload
: (label ?? t.common.download);
const Icon =
state === "preparing"
? LoaderIcon
: state === "failed"
? RotateCwIcon
: state === "requested"
? CheckIcon
: DownloadIcon;
return (
<div className="flex min-w-0 flex-col items-center gap-1">
<Button
type="button"
variant={compact ? "ghost" : "outline"}
disabled={state === "preparing"}
aria-label={`${text} ${filename}`}
title={`${text} ${filename}`}
onClick={async (event) => {
event.stopPropagation();
if (request.current) return;
const controller = new AbortController();
request.current = controller;
setState("preparing");
try {
const url = urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
});
const blob = await prepareArtifactDownload(url, controller.signal);
controller.signal.throwIfAborted();
const link = document.createElement("a");
const objectUrl = blob ? URL.createObjectURL(blob) : undefined;
if (objectUrl) urls.current.add(objectUrl);
link.href = objectUrl ?? url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
if (objectUrl)
setTimeout(() => {
URL.revokeObjectURL(objectUrl);
urls.current.delete(objectUrl);
}, 30_000);
setState("requested");
} catch {
if (!controller.signal.aborted) setState("failed");
} finally {
if (request.current === controller) request.current = null;
}
}}
>
<Icon
className={state === "preparing" ? "size-4 animate-spin" : "size-4"}
/>
{!compact && text}
</Button>
<span
role="status"
className={
compact && state !== "failed"
? "sr-only"
: "text-muted-foreground max-w-36 text-center text-xs"
}
>
{state === "failed"
? t.artifactPreview.downloadFailed
: state === "preparing"
? t.artifactPreview.preparingDownload
: state === "requested"
? t.artifactPreview.downloadRequested
: ""}
</span>
</div>
);
}
export function ArtifactDownloadButton(props: Props) {
const { user } = useAuth();
return (
<DownloadAction
key={JSON.stringify([
user?.id,
props.threadId,
props.filepath,
props.isMock,
])}
{...props}
/>
);
}

View File

@ -2,7 +2,6 @@ import { useQueryClient } from "@tanstack/react-query";
import {
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
@ -56,6 +55,7 @@ import {
} from "@/core/artifacts/viewer";
import { useAuth } from "@/core/auth/AuthProvider";
import { writeTextToClipboard } from "@/core/clipboard";
import { useArtifactPreviewRenderer } from "@/core/extensions/artifact-previews";
import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill, SkillRequestError } from "@/core/skills/api";
@ -68,16 +68,18 @@ import {
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { FileTypeIcon } from "../file-type-icon";
import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { ArtifactDownloadButton } from "./artifact-download-button";
import {
ArtifactDownloadFallback,
ArtifactFilePreview,
ArtifactPreviewError,
formatArtifactBytes,
} from "./artifact-file-preview";
import { useArtifacts } from "./context";
import { PluginArtifactPreview } from "./plugin-artifact-preview";
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
@ -116,6 +118,7 @@ export function ArtifactFileDetail({
[filepathFromProps, isWriteFile],
);
const filepath = parsedWriteFile?.path ?? filepathFromProps;
const { match: pluginPreview } = useArtifactPreviewRenderer(filepath);
// Keep these local because ChatBox replaces context artifacts with thread state.
const [openedPresentedFilepaths, setOpenedPresentedFilepaths] = useState<
string[]
@ -437,7 +440,8 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="px-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-1">
<FileTypeIcon filename={filepath} compact />
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
@ -602,7 +606,12 @@ export function ArtifactFileDetail({
}
onClick={() => {
const w = window.open(
resolveArtifactOpenURL({ filepath, threadId, isMock }),
resolveArtifactOpenURL({
filepath,
threadId,
isMock,
hasPluginPreview: !!pluginPreview,
}),
"_blank",
"noopener,noreferrer",
);
@ -634,27 +643,11 @@ export function ArtifactFileDetail({
/>
)}
{!isEditing && !isWriteFile && (
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={
isTabular && isDirty
? t.artifactTable.savedVersion
: t.common.download
}
onClick={() => {
const w = window.open(
urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
}),
"_blank",
"noopener,noreferrer",
);
if (w) w.opener = null;
}}
<ArtifactDownloadButton
filepath={filepath}
threadId={threadId}
isMock={isMock}
compact
/>
)}
<ArtifactAction
@ -772,10 +765,11 @@ export function ArtifactFileDetail({
/>
)}
{!isCodeFile && !canPreviewInBrowser && (
<ArtifactDownloadFallback
<PluginArtifactPreview
filepath={filepath}
threadId={threadId}
isMock={isMock}
isRunning={thread.isLoading}
/>
)}
</div>

View File

@ -4,31 +4,22 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileName } from "@/components/workspace/file-name";
import { FileTypeIcon } from "@/components/workspace/file-type-icon";
import {
ArtifactRequestError,
downloadArtifactArchive,
getArtifactArchiveManifest,
MAX_ARTIFACT_ARCHIVE_FILES,
} from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill, SkillRequestError } from "@/core/skills/api";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { ArtifactDownloadButton } from "./artifact-download-button";
import { useArtifacts } from "./context";
export function ArtifactFileList({
@ -159,56 +150,51 @@ export function ArtifactFileList({
</p>
</div>
)}
<ul className="flex w-full flex-col gap-4">
<ul className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))] gap-2">
{files.map((file) => (
<Card
<li
key={file}
className="relative cursor-pointer p-3"
onClick={() => handleClick(file)}
className="bg-card hover:bg-accent/40 flex min-w-0 items-center gap-1 rounded-xl border p-1.5 transition-colors"
>
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
<div className="min-w-0">{getFileName(file)}</div>
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
</div>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && isAdmin && (
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
{t.common.install}
</Button>
)}
<Button variant="ghost" asChild>
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<DownloadIcon className="size-4" />
{t.common.download}
</a>
<button
type="button"
aria-label={`${getFileName(file)} ${getFileExtensionDisplayName(file)}`}
onClick={() => handleClick(file)}
className="focus-visible:ring-ring flex min-w-0 flex-1 items-center gap-3 rounded-lg p-2 text-left outline-none focus-visible:ring-2"
>
<FileTypeIcon filename={file} />
<span className="min-w-0 flex-1 space-y-1">
<FileName
filename={getFileName(file)}
className="text-sm font-medium"
/>
<span className="text-muted-foreground block text-xs">
{getFileExtensionDisplayName(file)}
</span>
</span>
</button>
<div className="flex shrink-0 items-center">
{file.endsWith(".skill") && isAdmin && (
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
{t.common.install}
</Button>
</CardAction>
</CardHeader>
</Card>
)}
<ArtifactDownloadButton
filepath={file}
threadId={threadId}
compact
/>
</div>
</li>
))}
</ul>
</div>

View File

@ -1,11 +1,12 @@
"use client";
import { DownloadIcon } from "lucide-react";
import { RotateCwIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defaultUrlTransform, type UrlTransform } from "streamdown";
import { Button } from "@/components/ui/button";
import { FileTypeIcon } from "@/components/workspace/file-type-icon";
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
import {
getTabularDelimiter,
@ -16,7 +17,6 @@ import {
import {
type ArtifactImageContext,
resolveArtifactImageURL,
urlOfArtifact,
} from "@/core/artifacts/utils";
import { extractCitationSources } from "@/core/citations/sources";
import { useI18n } from "@/core/i18n/hooks";
@ -24,15 +24,12 @@ import {
SafeStreamdown,
toStreamdownComponents,
} from "@/core/streamdown/components";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { ArtifactLink } from "../citations/artifact-link";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { ArtifactDownloadButton } from "./artifact-download-button";
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
export function ArtifactPreviewError({
@ -41,32 +38,34 @@ export function ArtifactPreviewError({
isMock,
message,
downloadLabel,
onRetry,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
message: string;
downloadLabel: string;
onRetry?: () => void;
}) {
const { t } = useI18n();
return (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">{message}</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
{downloadLabel}
</a>
</Button>
<div className="flex flex-wrap justify-center gap-2">
{onRetry && (
<Button variant="outline" onClick={onRetry}>
<RotateCwIcon className="size-4" />
{t.artifactPreview.retry}
</Button>
)}
<ArtifactDownloadButton
filepath={filepath}
threadId={threadId}
isMock={isMock}
label={downloadLabel}
/>
</div>
</div>
</div>
);
@ -90,35 +89,24 @@ export function ArtifactDownloadFallback({
}) {
const filename = getFileName(filepath);
const fileType = getFileExtensionDisplayName(filepath);
const { t } = useI18n();
return (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="text-muted-foreground">
{getFileIcon(filepath, "size-12")}
</div>
<FileTypeIcon filename={filepath} className="size-16 [&>svg]:size-9" />
<div className="space-y-1">
<div className="font-medium break-all">{filename}</div>
<div className="text-muted-foreground text-sm">{fileType} file</div>
<div className="text-muted-foreground text-sm">{fileType}</div>
</div>
<p className="text-muted-foreground text-sm">
This file type cannot be previewed in the browser.
{t.artifactPreview.unavailable}
</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
Download
</a>
</Button>
<ArtifactDownloadButton
filepath={filepath}
threadId={threadId}
isMock={isMock}
/>
</div>
</div>
);

View File

@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import { ExternalLinkIcon, LoaderIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useStandaloneArtifactContent } from "@/core/artifacts/hooks";
@ -8,15 +8,48 @@ import { getTabularDelimiter } from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { resolveStoredArtifactLanguage } from "@/core/artifacts/viewer";
import { useI18n } from "@/core/i18n/hooks";
import { getFileIcon, getFileName } from "@/core/utils/files";
import { checkCodeFile, getFileIcon, getFileName } from "@/core/utils/files";
import { FileTypeIcon } from "../file-type-icon";
import { ArtifactDownloadButton } from "./artifact-download-button";
import {
formatArtifactBytes,
ArtifactDownloadFallback,
ArtifactFilePreview,
} from "./artifact-file-preview";
import { PluginArtifactPreview } from "./plugin-artifact-preview";
type ViewerProps = { filepath: string; threadId: string; isMock?: boolean };
export function ArtifactViewer(props: ViewerProps) {
if (
checkCodeFile(props.filepath).isCodeFile ||
props.filepath.endsWith(".skill")
)
return <TextArtifactViewer {...props} />;
return (
<div className="bg-background flex h-screen flex-col">
<header className="border-border flex shrink-0 items-center gap-3 border-b px-4 py-3">
<FileTypeIcon filename={props.filepath} compact />
<span className="min-w-0 flex-1 truncate font-medium">
{getFileName(props.filepath)}
</span>
<ArtifactDownloadButton {...props} />
</header>
<main className="min-h-0 flex-1">
{props.isMock ? (
<ArtifactDownloadFallback {...props} />
) : (
<PluginArtifactPreview {...props} />
)}
</main>
</div>
);
}
/** Shared standalone reader for Markdown and bounded CSV/TSV previews. */
export function ArtifactViewer({
function TextArtifactViewer({
filepath,
threadId,
isMock = false,
@ -67,19 +100,11 @@ export function ArtifactViewer({
{t.artifactPreview.viewSource}
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
isMock,
download: true,
})}
>
<DownloadIcon className="size-4" />
{t.common.download}
</a>
</Button>
<ArtifactDownloadButton
filepath={filepath}
threadId={threadId}
isMock={isMock}
/>
</header>
{truncated && !isTabular && (

View File

@ -0,0 +1,182 @@
"use client";
import { LoaderIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { readPreviewFile } from "@/core/artifacts/binary-preview";
import { readingStateAccess } from "@/core/artifacts/reading-state";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useArtifactPreviewRenderer } from "@/core/extensions/artifact-previews";
import type { ArtifactPreviewRenderer } from "@/core/extensions/contracts";
import { useI18n } from "@/core/i18n/hooks";
import { getFileName } from "@/core/utils/files";
import {
ArtifactDownloadFallback,
ArtifactPreviewError,
} from "./artifact-file-preview";
function MountedPreview({
renderer,
filepath,
threadId,
isMock,
viewerId,
rendererKey,
}: {
renderer: ArtifactPreviewRenderer;
filepath: string;
threadId: string;
isMock?: boolean;
viewerId: string;
rendererKey: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { locale, t } = useI18n();
const [failed, setFailed] = useState(false);
useEffect(() => {
if (failed || !ref.current) return;
const abort = new AbortController();
let state: ReturnType<typeof readingStateAccess> | undefined;
const shadow =
ref.current.shadowRoot ?? ref.current.attachShadow({ mode: "open" });
const root = document.createElement("div");
root.style.height = "100%";
shadow.replaceChildren(root);
let controller: { dispose: () => void } | undefined;
const onError = () => {
if (!abort.signal.aborted) setFailed(true);
};
try {
controller = renderer.mount(root, {
filename: getFileName(filepath),
locale,
signal: abort.signal,
onError,
getReadingState: () => state?.get(),
saveReadingState: (value) => {
if (!abort.signal.aborted) state?.set(value);
},
readFile: async () => {
state = undefined;
const { bytes, etag } = await readPreviewFile(
urlOfArtifact({ filepath, threadId, isMock }),
abort.signal,
);
let version = etag ? `etag:${etag}` : undefined;
if (!version && globalThis.crypto?.subtle) {
try {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
bytes,
);
version = `sha256:${Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("")}`;
} catch {
// Reading preferences must never prevent a successful preview.
}
}
abort.signal.throwIfAborted();
// LAN HTTP origins may have no Web Crypto. Without a version, skip
// restoring preferences rather than borrowing state from an older file.
if (version)
state = readingStateAccess(
viewerId,
JSON.stringify([threadId, filepath, rendererKey, version]),
);
return bytes;
},
});
if (typeof controller?.dispose !== "function")
throw new Error("Invalid preview controller");
} catch {
onError();
}
return () => {
abort.abort();
try {
controller?.dispose();
} catch {
/* Contain extension cleanup failures. */
}
root.remove();
};
}, [
renderer,
filepath,
threadId,
isMock,
locale,
failed,
viewerId,
rendererKey,
]);
return failed ? (
<ArtifactPreviewError
filepath={filepath}
threadId={threadId}
isMock={isMock}
message={t.artifactPreview.previewFailed}
downloadLabel={t.common.download}
onRetry={() => setFailed(false)}
/>
) : (
<div
ref={ref}
className="size-full min-w-0"
aria-label={getFileName(filepath)}
/>
);
}
/** Only installed, enabled, viewer-owned contributions can render files. */
export function PluginArtifactPreview(props: {
filepath: string;
threadId: string;
isMock?: boolean;
isRunning?: boolean;
}) {
const { match, isLoading, viewerId } = useArtifactPreviewRenderer(
props.filepath,
);
const { locale, t } = useI18n();
const [revision, setRevision] = useState(0);
const previous = useRef({
threadId: props.threadId,
filepath: props.filepath,
isRunning: props.isRunning,
});
useEffect(() => {
const old = previous.current;
previous.current = {
threadId: props.threadId,
filepath: props.filepath,
isRunning: props.isRunning,
};
if (
old.threadId === props.threadId &&
old.filepath === props.filepath &&
old.isRunning &&
!props.isRunning
)
setRevision((value) => value + 1);
}, [props.threadId, props.filepath, props.isRunning]);
if (isLoading)
return (
<div className="text-muted-foreground flex items-center gap-2 p-6 text-sm">
<LoaderIcon className="size-4 animate-spin" />
{t.artifactPreview.loadingPreview}
</div>
);
if (!match) return <ArtifactDownloadFallback {...props} />;
return (
<MountedPreview
key={`${viewerId}:${props.threadId}:${props.filepath}:${locale}:${match.contribution.namespace}:${revision}`}
renderer={match.renderer}
viewerId={viewerId ?? "anonymous"}
rendererKey={`${match.contribution.namespace}:${match.renderer.id}`}
{...props}
/>
);
}

View File

@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
/** Keep the extension visible when space is tight; the full name stays available. */
export function FileName({
filename,
className,
}: {
filename: string;
className?: string;
}) {
const dot = filename.lastIndexOf(".");
const split = dot > 0 && dot < filename.length - 1;
return (
<span title={filename} className={cn("flex min-w-0", className)}>
<span className="truncate">
{split ? filename.slice(0, dot) : filename}
</span>
{split && <span className="shrink-0">{filename.slice(dot)}</span>}
</span>
);
}

View File

@ -0,0 +1,37 @@
import { getFileExtension, getFileIcon } from "@/core/utils/files";
import { cn } from "@/lib/utils";
/** File-format colors are identification cues, shared by uploads and outputs. */
export function FileTypeIcon({
filename,
className,
compact = false,
}: {
filename: string;
className?: string;
compact?: boolean;
}) {
const extension = getFileExtension(filename);
const kind = ["xls", "xlsx", "csv", "tsv"].includes(extension)
? "sheet"
: ["doc", "docx"].includes(extension)
? "document"
: ["ppt", "pptx"].includes(extension)
? "slides"
: ["json", "jsonc", "json5"].includes(extension)
? "data"
: "file";
return (
<span
aria-hidden="true"
data-file-kind={kind}
className={cn(
"file-type-icon inline-flex shrink-0 items-center justify-center rounded-xl",
compact ? "size-6 rounded-md" : "size-11",
className,
)}
>
{getFileIcon(filename, compact ? "size-4" : "size-6")}
</span>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import {
CheckIcon,
LoaderIcon,
RotateCwIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import { useSyncExternalStore } from "react";
import {
PromptInputHoverCard,
PromptInputHoverCardContent,
usePromptInputAttachments,
type PromptInputAttachmentProps,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { HoverCardTrigger } from "@/components/ui/hover-card";
import { useI18n } from "@/core/i18n/hooks";
import type { AttachmentUploadSession } from "@/core/uploads/attachment-upload-session";
import { formatUploadSize } from "@/core/uploads/file-validation";
import { getFileExtensionDisplayName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { FileName } from "./file-name";
import { FileTypeIcon } from "./file-type-icon";
const noSubscribe = () => () => undefined;
const emptySnapshot = () => 0;
export function InputAttachment({
data,
className,
uploadSession,
...props
}: PromptInputAttachmentProps & { uploadSession?: AttachmentUploadSession }) {
const attachments = usePromptInputAttachments();
const { t } = useI18n();
useSyncExternalStore(
uploadSession?.subscribe ?? noSubscribe,
uploadSession?.snapshot ?? emptySnapshot,
emptySnapshot,
);
const status = uploadSession?.status(data) ?? "pending";
const uploadError = uploadSession?.error(data);
const filename = data.filename ?? "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel =
filename || (isImage ? t.uploads.image : t.uploads.attachment);
return (
<PromptInputHoverCard>
<HoverCardTrigger asChild>
<div
className={cn(
"group border-border bg-background hover:bg-accent relative flex h-14 max-w-full items-center gap-2 rounded-xl border py-1 pr-1 pl-2.5 text-sm transition-colors select-none sm:max-w-72",
className,
)}
key={data.id}
{...props}
>
<div className="relative size-6 shrink-0">
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded">
{isImage ? (
<img
alt=""
className="size-5 object-cover"
height={20}
src={data.url}
width={20}
/>
) : (
<FileTypeIcon filename={filename} compact />
)}
</div>
</div>
<span className="min-w-0 flex-1">
<FileName
filename={attachmentLabel}
className="text-xs font-medium"
/>
<span className="text-muted-foreground block text-[10px]">
{getFileExtensionDisplayName(filename)}
{data.file && ` · ${formatUploadSize(data.file.size)}`}
</span>
{uploadSession && (
<span
role="status"
className={cn(
"flex items-center gap-1 text-[10px]",
status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{status === "uploading" && (
<LoaderIcon className="size-3 animate-spin" />
)}
{status === "uploaded" && <CheckIcon className="size-3" />}
{t.uploads.status[status]}
</span>
)}
</span>
{status === "uploading" && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 shrink-0"
aria-label={`${t.uploads.cancelUpload}: ${attachmentLabel}`}
onClick={(e) => {
e.stopPropagation();
uploadSession?.cancel(data);
}}
>
<SquareIcon className="size-3.5" />
</Button>
)}
{(status === "failed" || status === "cancelled") && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 shrink-0"
aria-label={`${t.uploads.retryUpload}: ${attachmentLabel}`}
onClick={(e) => {
e.stopPropagation();
void uploadSession?.retry(data);
}}
>
<RotateCwIcon className="size-3.5" />
</Button>
)}
<Button
aria-label={t.uploads.removeAttachment(attachmentLabel)}
title={t.uploads.removeAttachment(attachmentLabel)}
className="text-muted-foreground size-10 shrink-0 cursor-pointer rounded-lg p-0 [&>svg]:size-3.5"
onClick={(e) => {
e.stopPropagation();
uploadSession?.cancel(data);
attachments.remove(data.id);
}}
type="button"
variant="ghost"
>
<XIcon />
</Button>
</div>
</HoverCardTrigger>
<PromptInputHoverCardContent className="max-w-[calc(100vw-2rem)] p-2">
<div className="w-auto space-y-3">
{isImage && (
<div className="flex max-h-96 w-96 max-w-full items-center justify-center overflow-hidden rounded-md border">
<img
alt={attachmentLabel}
className="max-h-full max-w-full object-contain"
height={384}
src={data.url}
width={448}
/>
</div>
)}
<div className="flex items-center gap-2.5">
<div className="min-w-0 flex-1 space-y-1 px-0.5">
<h4 className="text-sm leading-snug font-semibold break-all">
{attachmentLabel}
</h4>
{data.mediaType && (
<p className="text-muted-foreground truncate font-mono text-xs">
{data.mediaType}
</p>
)}
</div>
</div>
{uploadError && (
<p className="text-destructive max-w-72 text-xs break-words">
{uploadError}
</p>
)}
</div>
</PromptInputHoverCardContent>
</PromptInputHoverCard>
);
}

View File

@ -45,7 +45,6 @@ import {
PromptInputActionMenuContent,
PromptInputActionMenuItem,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputButton,
PromptInputFooter,
@ -137,6 +136,7 @@ import {
type UploadLimits,
type UploadLimitViolation,
} from "@/core/uploads";
import { AttachmentUploadSession } from "@/core/uploads/attachment-upload-session";
import {
appendSpeechTranscript,
getSpeechRecognitionConstructor,
@ -158,6 +158,7 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { InputAttachment } from "./input-attachment";
import {
abortGoalRequest,
beginGoalRequest,
@ -267,6 +268,7 @@ function escapeXmlAttribute(value: string) {
}
export type InputBoxSubmitOptions = {
attachmentUploads?: AttachmentUploadSession;
additionalKwargs?: Record<string, unknown>;
additionalInputMessages?: Message[];
/** Thread IDs attached through the conversation picker; sent as run context. */
@ -903,6 +905,16 @@ export function InputBox({
}),
[context.agent_name, draftAgentName, draftThreadId, user?.id],
);
// A different draft owns a fresh cache, even when its files have the same names.
const attachmentUploads = useMemo(
() => new AttachmentUploadSession(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[draftKey],
);
useLayoutEffect(() => {
attachmentUploads.activate();
return () => attachmentUploads.deactivate();
}, [attachmentUploads]);
const agentScopedSkills = useMemo(
() =>
agentSkillsLoading ? [] : filterSkillsForAgent(skills, agentSkillNames),
@ -1540,6 +1552,7 @@ export function InputBox({
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
};
const submitOptions: InputBoxSubmitOptions = {
attachmentUploads,
...(Object.keys(additionalKwargs).length ? { additionalKwargs } : {}),
...(quotes.length
? {
@ -1619,6 +1632,7 @@ export function InputBox({
textInput.value,
conversationReferences,
draftKey,
attachmentUploads,
invalidateDraftSaveTimer,
onContextChange,
onSubmit,
@ -3127,7 +3141,10 @@ export function InputBox({
<PromptInputAttachments className="contents p-0">
{(attachment) => (
<div className="max-w-60">
<PromptInputAttachment data={attachment} />
<InputAttachment
data={attachment}
uploadSession={attachmentUploads}
/>
</div>
)}
</PromptInputAttachments>

View File

@ -1,7 +1,6 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
CheckIcon,
FileIcon,
Loader2Icon,
PencilIcon,
ThumbsDownIcon,
@ -60,11 +59,14 @@ import { pathOfThread } from "@/core/threads/utils";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts/context";
import { WorkspaceChangeBadge } from "../changes";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { KnowledgeSourcesPanel } from "../citations/knowledge-source";
import { ConversationReferenceChip } from "../conversation-references/conversation-reference-chip";
import { CopyButton } from "../copy-button";
import { FileName } from "../file-name";
import { FileTypeIcon } from "../file-type-icon";
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
import { SlashSkillChip } from "../slash-skill-chip";
import { Tooltip } from "../tooltip";
@ -763,6 +765,7 @@ function RichFileCard({
threadId: string;
}) {
const { t } = useI18n();
const { select, setOpen } = useArtifacts();
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
@ -771,12 +774,10 @@ function RichFileCard({
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
<div className="flex items-start gap-2">
<Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
<FileName
filename={file.filename}
className="text-foreground text-sm font-medium"
/>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
@ -817,28 +818,26 @@ function RichFileCard({
}
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
<button
type="button"
aria-label={`${file.filename} ${getFileTypeLabel(file.filename)} · ${formatBytes(file.size)}`}
onClick={() => {
select(file.path!);
setOpen(true);
}}
className="bg-background border-border hover:bg-accent focus-visible:ring-ring flex max-w-64 min-w-40 items-center gap-3 rounded-xl border p-3 text-left transition-colors outline-none focus-visible:ring-2"
>
<FileTypeIcon filename={file.filename} />
<span className="min-w-0">
<FileName
filename={file.filename}
className="text-foreground text-sm font-medium"
/>
<span className="text-muted-foreground mt-1 block text-xs">
{getFileTypeLabel(file.filename)} · {formatBytes(file.size)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</div>
</span>
</button>
);
}

View File

@ -0,0 +1,47 @@
import { fetch } from "@/core/api/fetcher";
export const MAX_BINARY_PREVIEW_BYTES = 25 * 1024 * 1024;
/** Bounded even when Content-Length is absent or misleading. */
export async function readPreviewFile(
url: string,
signal: AbortSignal,
): Promise<{ bytes: ArrayBuffer; etag: string | null }> {
signal.throwIfAborted();
const response = await fetch(url, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`Preview unavailable (${response.status})`);
const size = Number(response.headers.get("content-length"));
if (size > MAX_BINARY_PREVIEW_BYTES) {
await response.body?.cancel();
throw new Error("Preview file too large");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Preview body unavailable");
const chunks: Uint8Array[] = [];
let length = 0;
try {
while (true) {
signal.throwIfAborted();
const { value, done } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > MAX_BINARY_PREVIEW_BYTES)
throw new Error("Preview file too large");
chunks.push(value);
}
signal.throwIfAborted();
} finally {
try {
await reader.cancel();
} finally {
reader.releaseLock();
}
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return { bytes: bytes.buffer, etag: response.headers.get("etag") };
}

View File

@ -0,0 +1,45 @@
import { fetch } from "@/core/api/fetcher";
export const MAX_BUFFERED_DOWNLOAD_BYTES = 64 * 1024 * 1024;
/** Large/unknown streams fall back to the browser's disk-backed download path. */
export async function prepareArtifactDownload(
url: string,
signal: AbortSignal,
): Promise<Blob | null> {
const response = await fetch(url, { signal, cache: "no-store" });
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Download failed (${response.status})`);
}
if (
Number(response.headers.get("content-length")) > MAX_BUFFERED_DOWNLOAD_BYTES
) {
await response.body?.cancel();
return null;
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Download body unavailable");
const chunks: Uint8Array<ArrayBuffer>[] = [];
let size = 0;
try {
while (true) {
signal.throwIfAborted();
const { value, done } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_BUFFERED_DOWNLOAD_BYTES) return null;
chunks.push(new Uint8Array(value));
}
signal.throwIfAborted();
return new Blob(chunks, {
type: response.headers.get("content-type") ?? "application/octet-stream",
});
} finally {
try {
await reader.cancel();
} finally {
reader.releaseLock();
}
}
}

View File

@ -0,0 +1,27 @@
/** Small, in-memory reading preferences; cleared when the authenticated viewer changes. */
const states = new Map<string, string>();
let owner: string | undefined;
export function readingStateAccess(viewer: string, key: string) {
if (owner !== viewer) {
states.clear();
owner = viewer;
}
return {
get: (): unknown =>
owner === viewer && states.has(key)
? JSON.parse(states.get(key)!)
: undefined,
set: (state: unknown) => {
if (owner !== viewer) return;
try {
const json = JSON.stringify(state);
if (!json || json.length > 16_384) return;
states.delete(key);
states.set(key, json);
while (states.size > 50) states.delete(states.keys().next().value!);
} catch {
/* Extension state must be serializable and bounded. */
}
},
};
}

View File

@ -31,7 +31,8 @@ export function resolveStoredArtifactLanguage(filepath: string) {
/**
* Target for the artifacts panel's "open in new window" action.
*
* Markdown and tabular files go to the in-app viewer route, which renders it with the same
* Markdown, tabular files and installed binary previews go to the in-app viewer route,
* which renders them with the same
* components as the panel instead of handing the browser a `text/markdown`
* response it can only show as raw source. Everything else keeps the raw
* Gateway URL — notably HTML, SVG, and other XML documents, which the Gateway
@ -42,13 +43,19 @@ export function resolveArtifactOpenURL({
filepath,
threadId,
isMock = false,
hasPluginPreview = false,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
hasPluginPreview?: boolean;
}) {
const language = resolveStoredArtifactLanguage(filepath);
if (language !== "markdown" && getTabularDelimiter(language) === null) {
if (
!hasPluginPreview &&
language !== "markdown" &&
getTabularDelimiter(language) === null
) {
return urlOfArtifact({ filepath, threadId, isMock });
}
return buildArtifactViewerURL({ filepath, threadId, isMock });

View File

@ -0,0 +1,30 @@
"use client";
import { useAuth } from "@/core/auth/AuthProvider";
import { getFileExtension } from "@/core/utils/files";
import { useFrontendExtensions } from "./hooks";
import { activeFrontendExtensions } from "./registry";
/** Use the same viewer fence and exact extension matching in both readers. */
export function useArtifactPreviewRenderer(filepath: string) {
const query = useFrontendExtensions();
const { user } = useAuth();
const match = user
? activeFrontendExtensions(query.data ?? [])
.filter(
({ contribution }) =>
!contribution.viewer_id || contribution.viewer_id === user.id,
)
.flatMap(({ contribution, extension }) =>
(extension.artifactPreviews ?? []).map((renderer) => ({
renderer,
contribution,
})),
)
.find(({ renderer }) =>
renderer.extensions.includes(getFileExtension(filepath)),
)
: undefined;
return { match, isLoading: query.isLoading, viewerId: user?.id };
}

View File

@ -454,6 +454,57 @@
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-lightrag#readme"
},
"deerflow-extension-office-preview": {
"schema_version": 1,
"id": "office-preview",
"locales": {
"en-US": {
"title": "Office file preview",
"description": "Preview Excel, Word and PowerPoint files in the side panel with an installed browser extension.",
"usage": [
{
"title": "Open an Office file",
"description": "Open an XLSX, DOCX or PPTX attachment or generated file in the right-hand reader."
},
{
"title": "Search and navigate",
"description": "Search displayed text and use worksheet tabs, page controls, outlines or thumbnails. Search shows up to 2,000 matches."
},
{
"title": "Download the original",
"description": "Download the original when a format is unsupported or a file exceeds preview limits."
}
],
"requirements": [
"Ask your administrator to install the Office preview extension, restart Gateway and refresh the browser.",
"Read-only XLSX, DOCX and PPTX files are supported, subject to the resource limits in the package guide."
]
},
"zh-CN": {
"title": "Office 文件预览",
"description": "安装浏览器扩展后,在右侧面板预览 Excel、Word 和 PowerPoint 文件。",
"usage": [
{
"title": "打开 Office 文件",
"description": "在右侧阅读面板打开 XLSX、DOCX 或 PPTX 附件或生成的文件。"
},
{
"title": "搜索与导航",
"description": "搜索显示的文字,并使用工作表标签、翻页、大纲或缩略图导航;最多显示 2,000 处匹配。"
},
{
"title": "下载原始文件",
"description": "文件格式不受支持或超过预览限制时,可下载原始文件。"
}
],
"requirements": [
"请管理员安装 Office 预览扩展、重启 Gateway,并刷新浏览器。",
"支持只读预览 XLSX、DOCX 和 PPTX,资源限制详见扩展使用说明。"
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-office-preview#readme"
},
"deerflow-extension-report-sections": {
"schema_version": 1,
"id": "community.report-sections",

View File

@ -5,6 +5,12 @@ import type { LoadedContribution } from "./registry";
// Discovery metadata only: catalog entries never enter the runtime module loader.
export const extensionCatalog = [
{
key: "officePreview",
package: "deerflow-extension-office-preview",
namespace: "office-preview",
icon: "file-text",
},
{
key: "agentTeams",
icon: "users",

View File

@ -105,6 +105,27 @@ export type MentionProvider = {
) => Promise<readonly { id: string; label: string; description?: string }[]>;
};
/** Optional file renderer. The host owns the URL, authorization and byte limit. */
export type ArtifactPreviewContext = {
filename: string;
locale: string;
signal: AbortSignal;
readFile: () => Promise<ArrayBuffer>;
onError: () => void;
/** Available after readFile; scoped to viewer, file bytes and renderer. In-memory only. */
getReadingState?: () => unknown;
saveReadingState?: (state: unknown) => void;
};
export type ArtifactPreviewRenderer = {
id: string;
/** Lowercase extensions without a dot; exact matches only. */
extensions: string[];
mount: (
root: HTMLElement,
context: ArtifactPreviewContext,
) => { dispose: () => void };
};
/** Browser package API v1. Modules are installed by trusted deployment operators. */
export interface FrontendExtension {
apiVersion: 1;
@ -112,6 +133,7 @@ export interface FrontendExtension {
icon?: string;
surfaces?: PluginSurface[];
mentionProviders?: MentionProvider[];
artifactPreviews?: ArtifactPreviewRenderer[];
conversationActions?: (
t: Translations,
locale?: string,

View File

@ -89,6 +89,32 @@ export async function loadFrontendExtensions(
typeof loadedModule.conversationActions !== "function")
)
throw new Error("Incompatible browser extension");
if (loadedModule.artifactPreviews !== undefined) {
const seen = new Set<string>();
if (
!Array.isArray(loadedModule.artifactPreviews) ||
loadedModule.artifactPreviews.length > 16
)
throw new Error("Invalid artifact previews");
for (const renderer of loadedModule.artifactPreviews) {
if (
!renderer ||
typeof renderer.id !== "string" ||
!/^[a-z][a-z0-9-]{0,63}$/.test(renderer.id) ||
seen.has(renderer.id) ||
typeof renderer.mount !== "function" ||
!Array.isArray(renderer.extensions) ||
renderer.extensions.length === 0 ||
renderer.extensions.length > 32 ||
renderer.extensions.some(
(ext) =>
typeof ext !== "string" || !/^[a-z0-9]{1,16}$/.test(ext),
)
)
throw new Error("Invalid artifact preview renderer");
seen.add(renderer.id);
}
}
if (loadedModule.mentionProviders !== undefined) {
const seen = new Set<string>();
if (

View File

@ -123,6 +123,7 @@ export const enUS: Translations = {
"Refresh the page after your administrator updates an extension.",
},
catalog: {
officePreview: bundledCopy("deerflow-extension-office-preview", "en-US"),
agentTeams: bundledCopy("deerflow-extension-agent-teams", "en-US"),
bookmarks: bundledCopy("deerflow-extension-bookmarks", "en-US"),
context: bundledCopy("deerflow-extension-jev-context", "en-US"),
@ -381,6 +382,13 @@ export const enUS: Translations = {
},
artifactPreview: {
preparingDownload: "Preparing download…",
retryDownload: "Retry download",
downloadFailed: "Download failed. Try again.",
downloadRequested: "Sent to browser downloads",
retry: "Retry preview",
unavailable:
"Preview is unavailable for this format. Download the original file to view it.",
loadingPreview: "Preparing preview...",
resourcesUnavailable:
"Some preview resources could not be loaded. View the source or download the files for the complete content.",
@ -1967,6 +1975,20 @@ export const enUS: Translations = {
// Subtasks
uploads: {
status: {
pending: "Ready to send",
uploading: "Uploading…",
uploaded: "Uploaded",
failed: "Upload failed",
cancelled: "Cancelled",
},
cancelUpload: "Cancel upload",
retryUpload: "Retry upload",
notReady:
"Attachments are not ready. Retry failed files or remove them, then send again.",
attachment: "Attachment",
image: "Image",
removeAttachment: (filename) => `Remove attachment: ${filename}`,
uploading: "Uploading...",
uploadingFiles: "Uploading files, please wait...",
limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) =>

View File

@ -98,6 +98,7 @@ export interface Translations {
refreshHint: string;
};
catalog: Record<
| "officePreview"
| "workflows"
| "reportSections"
| "agentTeams"
@ -329,6 +330,12 @@ export interface Translations {
};
artifactPreview: {
preparingDownload: string;
retryDownload: string;
downloadFailed: string;
downloadRequested: string;
retry: string;
unavailable: string;
loadingPreview: string;
resourcesUnavailable: string;
limited: (previewSize: string, totalSize?: string) => string;
@ -1666,6 +1673,19 @@ export interface Translations {
// Uploads
uploads: {
status: {
pending: string;
uploading: string;
uploaded: string;
failed: string;
cancelled: string;
};
cancelUpload: string;
retryUpload: string;
notReady: string;
attachment: string;
image: string;
removeAttachment: (filename: string) => string;
uploading: string;
uploadingFiles: string;
limitsHint: (

View File

@ -116,6 +116,7 @@ export const zhCN: Translations = {
refreshHint: "管理员更新扩展后,刷新页面以获取最新状态。",
},
catalog: {
officePreview: bundledCopy("deerflow-extension-office-preview", "zh-CN"),
agentTeams: bundledCopy("deerflow-extension-agent-teams", "zh-CN"),
bookmarks: bundledCopy("deerflow-extension-bookmarks", "zh-CN"),
context: bundledCopy("deerflow-extension-jev-context", "zh-CN"),
@ -365,6 +366,12 @@ export const zhCN: Translations = {
},
artifactPreview: {
preparingDownload: "正在准备下载…",
retryDownload: "重试下载",
downloadFailed: "下载失败,请重试。",
downloadRequested: "已交给浏览器下载",
retry: "重新预览",
unavailable: "当前格式暂无可用预览,可下载原始文件查看。",
loadingPreview: "正在准备预览...",
resourcesUnavailable:
"部分预览资源无法加载。可查看源代码或下载文件以获取完整内容。",
@ -1856,6 +1863,19 @@ export const zhCN: Translations = {
},
uploads: {
status: {
pending: "待发送",
uploading: "上传中…",
uploaded: "上传成功",
failed: "上传失败",
cancelled: "已取消",
},
cancelUpload: "取消上传",
retryUpload: "重试上传",
notReady: "附件尚未就绪。请重试失败项或将其移除,然后重新发送。",
attachment: "附件",
image: "图片",
removeAttachment: (filename) => `移除附件:${filename}`,
uploading: "上传中...",
uploadingFiles: "文件上传中,请稍候...",
limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) =>

View File

@ -52,6 +52,10 @@ import { useSubtaskContext, useUpdateSubtask } from "../tasks/context";
import { taskEventToSubtaskUpdate } from "../tasks/lifecycle";
import { messageToStep } from "../tasks/steps";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import {
AttachmentUploadError,
type AttachmentUploadSession,
} from "../uploads/attachment-upload-session";
import { uuid } from "../utils/uuid";
import {
@ -133,6 +137,7 @@ export type ThreadStreamOptions = {
};
type SendMessageOptions = {
attachmentUploads?: AttachmentUploadSession;
additionalKwargs?: Record<string, unknown>;
additionalInputMessages?: Message[];
/**
@ -3133,28 +3138,32 @@ export function useThreadStream({
if (message.files && message.files.length > 0) {
setIsUploading(true);
try {
const filePromises = message.files.map((fileUIPart) =>
promptInputFilePartToFile(fileUIPart),
);
const conversionResults = await Promise.all(filePromises);
const files = conversionResults.filter(
(file): file is File => file !== null,
);
const failedConversions = conversionResults.length - files.length;
if (failedConversions > 0) {
throw new Error(
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
);
}
if (!threadId) {
throw new Error("Thread is not ready for file upload.");
}
if (files.length > 0) {
const uploadResponse = await uploadFiles(threadId, files);
{
let uploadResponse;
if (options?.attachmentUploads) {
uploadResponse = {
files: await options.attachmentUploads.uploadAll(
threadId,
message.files,
),
};
} else {
const converted = await Promise.all(
message.files.map((part) => promptInputFilePartToFile(part)),
);
const files = converted.filter(
(file): file is File => file !== null,
);
if (files.length !== converted.length)
throw new Error(
"Failed to prepare attachments for upload. Please retry.",
);
uploadResponse = await uploadFiles(threadId, files);
}
uploadedFiles = uploadResponse.files.map((info) => ({
filename: info.filename,
size: info.size,
@ -3185,7 +3194,11 @@ export function useThreadStream({
error instanceof Error
? error.message
: "Failed to upload files.";
toast.error(errorMessage);
toast.error(
error instanceof AttachmentUploadError && error.code !== "failed"
? t.uploads.notReady
: errorMessage,
);
setOptimisticMessages([]);
setOptimisticThreadId(null);
setLiveMessagesThreadId(null);
@ -3251,6 +3264,7 @@ export function useThreadStream({
[
thread,
t.uploads.uploadingFiles,
t.uploads.notReady,
locale,
context,
queryClient,

View File

@ -126,6 +126,7 @@ async function readErrorDetail(
export async function uploadFiles(
threadId: string,
files: File[],
signal?: AbortSignal,
): Promise<UploadResponse> {
const formData = new FormData();
@ -138,6 +139,7 @@ export async function uploadFiles(
{
method: "POST",
body: formData,
signal,
},
);

View File

@ -0,0 +1,195 @@
import { uploadFiles, type UploadedFileInfo } from "./api";
import {
promptInputFilePartToFile,
type PromptInputFilePart,
} from "./prompt-input-files";
export type AttachmentUploadStatus =
| "pending"
| "uploading"
| "uploaded"
| "failed"
| "cancelled";
export type AttachmentUploadFailure = {
part: PromptInputFilePart;
cause: unknown;
};
export type AttachmentUploadErrorCode =
| "inactive"
| "thread_changed"
| "not_ready"
| "failed";
const sessionErrorMessages: Record<AttachmentUploadErrorCode, string> = {
inactive: "Upload session is inactive",
thread_changed: "Upload thread changed",
not_ready: "Attachments are not ready",
failed: "Failed to upload files.",
};
function failureMessage(cause: unknown): string | undefined {
const message = cause instanceof Error ? cause.message : cause;
return typeof message === "string" && message.trim() ? message : undefined;
}
/** Machine-readable session state and original per-file failures, separate from display text. */
export class AttachmentUploadError extends Error {
constructor(
readonly code: AttachmentUploadErrorCode,
readonly failures: readonly AttachmentUploadFailure[] = [],
) {
const cause = failures[0]?.cause;
super(failureMessage(cause) ?? sessionErrorMessages[code], { cause });
this.name = "AttachmentUploadError";
}
}
type Entry = {
status: AttachmentUploadStatus;
result?: UploadedFileInfo;
cause?: unknown;
controller?: AbortController;
promise?: Promise<void>;
};
/** One composer, one authenticated thread. Successful uploads survive a failed send. */
export class AttachmentUploadSession {
private entries = new Map<File | string, Entry>();
private listeners = new Set<() => void>();
private version = 0;
private active = true;
private threadId?: string;
private cancellations = 0;
constructor(private upload = uploadFiles) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
snapshot = () => this.version;
private emit() {
this.version++;
this.listeners.forEach((listener) => listener());
}
private key(part: PromptInputFilePart) {
return part.file ?? part.url;
}
status(part: PromptInputFilePart): AttachmentUploadStatus {
return this.entries.get(this.key(part))?.status ?? "pending";
}
error(part: PromptInputFilePart): string | undefined {
return failureMessage(this.entries.get(this.key(part))?.cause);
}
activate() {
this.active = true;
}
deactivate() {
this.cancellations++;
this.active = false;
for (const entry of this.entries.values()) {
entry.controller?.abort();
if (entry.status === "uploading") entry.status = "cancelled";
}
this.emit();
}
cancel(part: PromptInputFilePart) {
this.cancellations++;
const key = this.key(part);
const entry = this.entries.get(key) ?? { status: "pending" };
entry.controller?.abort();
this.entries.set(key, { status: "cancelled" });
this.emit();
}
async retry(part: PromptInputFilePart) {
if (!this.threadId || !this.active) return;
await this.start(part);
}
private start(part: PromptInputFilePart): Promise<void> {
const key = this.key(part),
current = this.entries.get(key);
if (current?.status === "uploaded") return Promise.resolve();
if (current?.status === "uploading") return current.promise!;
const controller = new AbortController();
const entry: Entry = { status: "uploading", controller };
this.entries.set(key, entry);
entry.promise = (async () => {
try {
if (!this.active || !this.threadId)
throw new Error("Upload session is inactive");
const file = await promptInputFilePartToFile(part, controller.signal);
controller.signal.throwIfAborted();
if (!file) throw new Error("Unable to prepare attachment");
const response = await this.upload(
this.threadId,
[file],
controller.signal,
);
controller.signal.throwIfAborted();
if (!this.active || this.entries.get(key) !== entry) return;
const result = response.files[0];
if (
!result ||
response.files.length !== 1 ||
response.skipped_files?.length
)
throw new Error("Attachment was not stored");
entry.result = result;
entry.status = "uploaded";
} catch (error) {
if (this.entries.get(key) === entry) {
entry.status = controller.signal.aborted ? "cancelled" : "failed";
if (entry.status === "failed") entry.cause = error;
}
} finally {
entry.controller = undefined;
this.emit();
}
})();
this.emit();
return entry.promise;
}
async uploadAll(
threadId: string,
parts: PromptInputFilePart[],
): Promise<UploadedFileInfo[]> {
if (!this.active) throw new AttachmentUploadError("inactive");
if (this.threadId && this.threadId !== threadId)
throw new AttachmentUploadError("thread_changed");
this.threadId = threadId;
const cancellations = this.cancellations;
// Three workers bound memory and requests. A cancelled queued item stays cancelled this attempt.
const queue = parts.map((part) => {
const key = this.key(part);
if (["failed", "cancelled"].includes(this.status(part)))
this.entries.set(key, { status: "pending" });
return part;
});
this.emit();
await Promise.all(
Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length && this.active) {
const part = queue.shift()!;
if (this.status(part) !== "cancelled") await this.start(part);
}
}),
);
if (
!this.active ||
cancellations !== this.cancellations ||
parts.some((part) => this.status(part) !== "uploaded")
) {
const failures = parts.flatMap((part) => {
const entry = this.entries.get(this.key(part));
return entry?.status === "failed" ? [{ part, cause: entry.cause }] : [];
});
throw new AttachmentUploadError(
failures.length ? "failed" : "not_ready",
failures,
);
}
return parts.map((part) => this.entries.get(this.key(part))!.result!);
}
}

View File

@ -7,6 +7,7 @@ export type PromptInputFilePart = FileUIPart & {
export async function promptInputFilePartToFile(
filePart: PromptInputFilePart,
signal?: AbortSignal,
): Promise<File | null> {
if (filePart.file instanceof File) {
const filename =
@ -30,7 +31,7 @@ export async function promptInputFilePartToFile(
}
try {
const response = await fetch(filePart.url);
const response = await fetch(filePart.url, { signal });
if (!response.ok) {
throw new Error(
`HTTP ${response.status} while fetching fallback file URL`,

View File

@ -5,7 +5,10 @@ import {
FileCogIcon,
FilePlayIcon,
FileTextIcon,
FileJsonIcon,
ImageIcon,
PresentationIcon,
SheetIcon,
} from "lucide-react";
const extensionMap: Record<string, string> = {
@ -225,6 +228,21 @@ export function getFileIcon(filepath: string, className?: string) {
const extension = getFileExtension(filepath);
const { isCodeFile } = checkCodeFile(filepath);
switch (extension) {
case "xlsx":
case "xls":
case "csv":
case "tsv":
return <SheetIcon className={className} />;
case "pptx":
case "ppt":
return <PresentationIcon className={className} />;
case "docx":
case "doc":
return <FileTextIcon className={className} />;
case "json":
case "jsonc":
case "json5":
return <FileJsonIcon className={className} />;
case "skill":
return <FileCogIcon className={className} />;
case "html":

View File

@ -1,6 +1,28 @@
@import "tailwindcss";
@import "tw-animate-css";
/* File-format identity colors; keep semantic tokens for the surrounding UI. */
.file-type-icon {
background: var(--muted);
color: var(--muted-foreground);
}
.file-type-icon[data-file-kind="sheet"] {
background: light-dark(#e7f5ec, #17392c);
color: light-dark(#187442, #70d6a1);
}
.file-type-icon[data-file-kind="document"] {
background: light-dark(#eaf1fd, #1d304d);
color: light-dark(#2864c5, #8bb7ff);
}
.file-type-icon[data-file-kind="slides"] {
background: light-dark(#fff0e8, #472b23);
color: light-dark(#bb4b28, #ffa17f);
}
.file-type-icon[data-file-kind="data"] {
background: light-dark(#f1edfb, #32284a);
color: light-dark(#7751b5, #c4a5ee);
}
@source "../../node_modules/streamdown/dist/index.js";
@source "../../node_modules/@streamdown/code/dist/*.js";
@source "../../node_modules/@streamdown/mermaid/dist/*.js";

View File

@ -18,6 +18,8 @@ test.describe("standalone artifact viewer access", () => {
test("renders a public showcase artifact without a session", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(
viewerUrl({
path: DEMO_ARTIFACT,
@ -31,6 +33,13 @@ test.describe("standalone artifact viewer access", () => {
page.getByText("fei-fei-li-podcast-timeline.md").first(),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator("h1").first()).toBeVisible({ timeout: 15_000 });
await expect(
page.getByRole("button", {
name: "Download fei-fei-li-podcast-timeline.md",
exact: true,
}),
).toBeVisible();
expect(errors).toEqual([]);
});
test("sends a logged-out visitor to login for a non-public artifact", async ({

View File

@ -1,5 +1,9 @@
# Browser E2E tests (`frontend/tests/e2e/`)
- **Install the Office fixture dependencies before collecting tests.** Run
`npm ci --ignore-scripts` in `examples/deerflow-extension-office-preview`.
Playwright loads the resource suite before it selects a shard.
- **A virtualized list does not keep every fetched row mounted.** Assert the
pagination request, then filter or scroll the row into the rendered window
before you check its content. Keep the real scroll trigger.

View File

@ -181,9 +181,11 @@ test.describe("Artifact preview stability", () => {
.first()
.click();
const panel = page.locator("#artifacts");
await expect(panel.getByRole("status")).toContainText(
"Some preview resources could not be loaded",
);
await expect(
panel.getByRole("status").filter({
hasText: "Some preview resources could not be loaded",
}),
).toContainText("Some preview resources could not be loaded");
const iframe = panel.locator('iframe[title="Artifact preview"]');
await expect(iframe).toHaveAttribute(
"sandbox",
@ -350,6 +352,7 @@ test.describe("Artifact preview stability", () => {
}) => {
const threadId = `00000000-0000-0000-0000-00000000313${index}`;
const filepath = `/mnt/user-data/outputs/${filename}`;
let downloadRequests = 0;
mockLangGraphAPI(page, {
threads: [
{
@ -360,17 +363,33 @@ test.describe("Artifact preview stability", () => {
},
],
});
await page.route(
`**/api/threads/${threadId}/artifacts${filepath}?download=true`,
async (route) => {
downloadRequests++;
await route.fulfill({
contentType: "application/octet-stream",
body: "Original artifact bytes",
});
},
);
await page.goto(`/workspace/chats/${threadId}`);
await page.getByText(filename, { exact: true }).first().click();
const artifactsPanel = page.locator("#artifacts");
const download = artifactsPanel.getByRole("link", { name: "Download" });
const download = artifactsPanel
.getByRole("button", {
name: `Download ${filename}`,
exact: true,
})
.last();
await expect(download).toBeVisible();
await expect(download).toHaveAttribute(
"href",
`/api/threads/${threadId}/artifacts${filepath}?download=true`,
);
expect(downloadRequests).toBe(0);
const received = page.waitForEvent("download");
await download.click();
expect((await received).suggestedFilename()).toBe(filename);
expect(downloadRequests).toBe(1);
await expect(artifactsPanel.locator(".cm-editor")).toHaveCount(0);
await expect(
artifactsPanel.getByRole("button", { name: "Edit", exact: true }),

View File

@ -0,0 +1,225 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const threadId = "00000000-0000-0000-0000-000000009812";
const names = ["navigation.xlsx"];
const qa = path.resolve("test-results/file-transfer");
async function setup(page: Page) {
await page.setViewportSize({ width: 1440, height: 960 });
await page.addInitScript(() => localStorage.setItem("theme", "light"));
const files = names.map((name) => `/mnt/user-data/outputs/${name}`);
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
title: "File transfer review",
artifacts: files,
messages: [
{ type: "human", id: "ask", content: "Review these files" },
{
type: "ai",
id: "ready",
content: "Files are ready.",
tool_calls: [
{
id: "present",
name: "present_files",
args: { filepaths: files },
},
],
},
],
},
],
});
await page.goto(`/workspace/chats/${threadId}`);
await expect(page.getByText("Files are ready.")).toBeVisible();
await mkdir(qa, { recursive: true });
}
test("single download displays preparation, suppresses repeated requests and retries a failure", async ({
page,
}) => {
await setup(page);
let attempts = 0;
let release!: () => void;
await page.route(
`**/api/threads/${threadId}/artifacts/**?download=true`,
async (route) => {
attempts++;
if (attempts === 1) {
await new Promise<void>((resolve) => {
release = resolve;
});
await route.fulfill({ status: 500, body: "download unavailable" });
} else
await route.fulfill({
body: "download fixture",
contentType: "application/octet-stream",
});
},
);
const button = page.getByRole("button", {
name: `Download ${names[0]}`,
exact: true,
});
await button.click();
await expect(
page.getByRole("button", {
name: `Preparing download… ${names[0]}`,
exact: true,
}),
).toBeDisabled();
await expect.poll(() => attempts).toBe(1);
await page.screenshot({
path: path.join(qa, "single-download-preparing-light.png"),
animations: "disabled",
});
release();
const retry = page.getByRole("button", {
name: `Retry download ${names[0]}`,
exact: true,
});
await expect(retry).toBeVisible();
await page.screenshot({
path: path.join(qa, "single-download-error-light.png"),
animations: "disabled",
});
const download = page.waitForEvent("download");
await retry.click();
expect((await download).suggestedFilename()).toBe(names[0]);
expect(attempts).toBe(2);
});
test("uploads retain successful files, cancel one file and retry only the failed file before explicit send", async ({
page,
}) => {
await setup(page);
const attempts: string[] = [];
const releases = new Map<string, () => void>();
let sends = 0;
page.on("request", (request) => {
if (
request.method() === "POST" &&
new URL(request.url()).pathname.endsWith("/runs/stream")
)
sends++;
});
await page.route(`**/api/threads/${threadId}/uploads`, async (route) => {
const filename = /filename="([^"]+)"/.exec(
route.request().postDataBuffer()!.toString(),
)![1]!;
attempts.push(filename);
if (
filename !== "success.txt" &&
attempts.filter((name) => name === filename).length === 1
) {
await new Promise<void>((resolve) => releases.set(filename, resolve));
await route
.fulfill({ status: 500, json: { detail: "Synthetic upload failure" } })
.catch(() => {
// A cancelled request may have disconnected before the fixture responds.
});
return;
}
await route.fulfill({
json: {
success: true,
skipped_files: [],
message: "Uploaded",
files: [
{
filename,
size: 8,
path: `/uploads/${filename}`,
virtual_path: `/mnt/user-data/uploads/${filename}`,
artifact_url: `/api/threads/${threadId}/artifacts/mnt/user-data/uploads/${filename}`,
},
],
},
});
});
const prompt = page
.locator("form")
.filter({ has: page.locator('textarea[name="message"]') });
await page.getByLabel("Upload files").setInputFiles(
["success.txt", "retry.txt", "cancel.txt"].map((name) => ({
name,
mimeType: "text/plain",
buffer: Buffer.from("fixture"),
})),
);
await expect(prompt.getByRole("status")).toHaveText([
"Ready to send",
"Ready to send",
"Ready to send",
]);
await page
.locator('textarea[name="message"]')
.fill("Review these attachments");
await prompt.getByRole("button", { name: "Submit", exact: true }).click();
await expect.poll(() => attempts.length).toBe(3);
await expect(prompt.getByRole("status")).toHaveText([
"Uploaded",
"Uploading…",
"Uploading…",
]);
await page.mouse.move(30, 30);
await page.screenshot({
path: path.join(qa, "uploads-in-progress-light.png"),
animations: "disabled",
});
await prompt
.getByRole("button", { name: "Cancel upload: cancel.txt", exact: true })
.click();
releases.get("retry.txt")!();
releases.get("cancel.txt")!();
await expect(prompt.getByRole("status")).toHaveText([
"Uploaded",
"Upload failed",
"Cancelled",
]);
await page.mouse.move(30, 30);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await expect(page.locator('textarea[name="message"]')).toHaveValue(
"Review these attachments",
);
expect(sends).toBe(0);
await page.screenshot({
path: path.join(qa, "uploads-partial-failure-light.png"),
animations: "disabled",
});
await prompt
.getByRole("button", { name: "Retry upload: retry.txt", exact: true })
.click();
await expect(prompt.getByRole("status")).toHaveText([
"Uploaded",
"Uploaded",
"Cancelled",
]);
expect(attempts).toEqual([
"success.txt",
"retry.txt",
"cancel.txt",
"retry.txt",
]);
expect(sends).toBe(0);
await prompt
.getByRole("button", { name: "Remove attachment: cancel.txt", exact: true })
.click();
await page.screenshot({
path: path.join(qa, "uploads-recovered-light.png"),
animations: "disabled",
});
await prompt.getByRole("button", { name: "Submit", exact: true }).click();
await expect.poll(() => sends).toBe(1);
await expect(prompt.getByTitle("success.txt", { exact: true })).toHaveCount(
0,
);
expect(attempts.filter((name) => name === "success.txt")).toHaveLength(1);
});

View File

@ -0,0 +1,26 @@
"""Synthetic chart-only drawing that ExcelJS 4.4 cannot reconcile."""
from pathlib import Path
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.worksheet.table import Table, TableStyleInfo
workbook = Workbook()
summary = workbook.active
summary.title = "Summary"
for row in (("Team", "Tasks"), ("Design", 8), ("Engineering", 12)):
summary.append(row)
table = Table(displayName="Teams", ref="A1:B3")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True)
summary.add_table(table)
chart = BarChart()
chart.title = "Tasks by team"
chart.add_data(Reference(summary, min_col=2, min_row=1, max_row=3), titles_from_data=True)
chart.set_categories(Reference(summary, min_col=1, min_row=2, max_row=3))
summary.add_chart(chart, "D2")
detail = workbook.create_sheet("Design")
detail.append(("Design review", "Ready"))
detail["A2"] = "Documentation"
detail["A2"].hyperlink = "https://example.com/design"
workbook.save(Path(__file__).with_name("chart-only.xlsx"))

View File

@ -0,0 +1,39 @@
"""Synthetic documents for keyboard and scroll regressions; no user data."""
from pathlib import Path
from docx import Document
from openpyxl import Workbook
root = Path(__file__).parent
workbook = Workbook()
sheet = workbook.active
sheet.title = "Navigation"
sheet.append(["Start", "Hidden column", "Visible column", "", "Last column"])
sheet.append(["Hidden row"])
sheet.append(["Visible row", "", "Merged cell", "", "After merge"])
sheet.append(["Last row", "", "Below merge"])
sheet.column_dimensions["B"].hidden = True
sheet.row_dimensions[2].hidden = True
sheet.merge_cells("C3:D3")
sheet["A5"] = "Merged across hidden row and column"
sheet.merge_cells("A5:C7")
sheet.row_dimensions[6].hidden = True
sheet["D5"] = "Beside merged range"
sheet["A8"] = "Below merged range"
workbook.create_sheet("Other sheet").append(["Other value"])
sheet["E8"] = "Search target alpha"
workbook["Other sheet"]["A2"] = "Search target beta"
sheet["B8"] = "Search target hidden"
sheet["A501"] = "Search target truncated"
workbook.save(root / "navigation.xlsx")
document = Document()
for page, title in enumerate(("First review page", "Second review page", "Final review page")):
if page:
document.add_page_break()
document.add_heading(title, 1)
document.add_paragraph("Synthetic document for page navigation and manual scrolling.")
paragraph = document.add_paragraph()
paragraph.add_run("Search ").bold = True
paragraph.add_run("target across styled runs.")
document.save(root / "navigation.docx")

View File

@ -0,0 +1,23 @@
"""Synthetic OOXML relationship fixture; no user data."""
import struct
import zlib
from io import BytesIO
from pathlib import Path
from docx import Document
from docx.shared import Inches
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00\x00\xff\x00" * 2)) + chunk(b"IEND", b"")
document = Document()
document.add_heading("Worker resource document", 1)
document.add_paragraph("Body text survives worker parsing.")
document.add_picture(BytesIO(png), width=Inches(0.25))
document.sections[0].header.paragraphs[0].text = "Worker header text"
document.sections[0].footer.paragraphs[0].text = "Worker footer text"
document.save(Path(__file__).with_name("worker-resources.docx"))

View File

@ -0,0 +1,7 @@
PROJECT DELIVERY
1. Review the quarterly report
2. Confirm the project brief
3. Share the presentation
All files are ready for review.

View File

@ -0,0 +1,9 @@
{
"project": "DeerFlow",
"status": "ready",
"deliverables": 5,
"review": {
"owner": "Design team",
"approved": true
}
}

View File

@ -0,0 +1,245 @@
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
import { mockOfficePreview, officeFixtures } from "./utils/office-preview";
const threadId = "00000000-0000-0000-0000-000000009812";
const names = ["navigation.xlsx", "navigation.docx", "project-review.pptx"];
const qa = path.resolve("test-results/office-preview");
async function setup(page: Page) {
await page.setViewportSize({ width: 1440, height: 960 });
await page.addInitScript(() => localStorage.setItem("theme", "light"));
const files = names.map((name) => `/mnt/user-data/outputs/${name}`);
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
title: "File interaction review",
artifacts: files,
messages: [
{ type: "human", id: "ask", content: "Review these files" },
{
type: "ai",
id: "ready",
content: "Files are ready.",
tool_calls: [
{
id: "present",
name: "present_files",
args: { filepaths: files },
},
],
},
],
},
],
});
await mockOfficePreview(page);
await page.route(`**/api/threads/${threadId}/artifacts/**`, async (route) => {
const name = new URL(route.request().url()).pathname.split("/").pop()!;
await route.fulfill({
body: await readFile(path.join(officeFixtures, name)),
contentType: "application/octet-stream",
});
});
await page.goto(`/workspace/chats/${threadId}`);
await expect(page.getByText("Files are ready.")).toBeVisible();
await mkdir(qa, { recursive: true });
}
async function open(page: Page, name: string) {
await page
.getByRole("button", {
name: `${name} ${name.endsWith("xlsx") ? "Excel" : name.endsWith("docx") ? "Word" : "PowerPoint"}`,
exact: true,
})
.click();
return page.locator("#artifacts");
}
async function switchFile(page: Page, name: string) {
await page.locator("#artifacts [role=combobox]").click();
await page.getByRole("option", { name, exact: true }).click();
}
const find = (panel: Locator) =>
panel.getByRole("searchbox", { name: "Find in file", exact: true });
test("Excel finds across visible sheets, excludes hidden and truncated cells and restores each file's reading state", async ({
page,
}) => {
await setup(page);
const panel = await open(page, names[0]!);
await expect(panel.getByRole("table", { name: "Navigation" })).toBeVisible();
await find(panel).fill(" Search target ");
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("1 / 2");
await expect(panel.locator('td[data-address="E8"] mark')).toHaveText(
"Search target",
);
await expect(panel.locator(".search-scope")).toContainText("500 rows");
await panel.getByRole("button", { name: "Next match", exact: true }).click();
await expect(
panel.getByRole("tab", { name: "Other sheet", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(panel.locator('td[data-address="A2"] mark')).toHaveAttribute(
"data-current",
"true",
);
await panel
.getByRole("button", { name: "Previous match", exact: true })
.click();
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("1 / 2");
await find(panel).fill("[literal].*");
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("0 matches");
await find(panel).fill("Search target");
await panel.getByRole("button", { name: "Next match", exact: true }).click();
await panel.getByRole("button", { name: "Zoom in", exact: true }).click();
const zoom = await panel.locator(".bar .muted").last().textContent();
await page.screenshot({
path: path.join(qa, "excel-cross-sheet-search-light.png"),
animations: "disabled",
});
await switchFile(page, names[1]!);
await expect(panel.locator("section.docx")).toHaveCount(3);
await switchFile(page, names[0]!);
await expect(
panel.getByRole("tab", { name: "Other sheet", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(panel.locator(".bar .muted").last()).toHaveText(zoom!);
await expect(panel.locator('td[data-address="A2"]')).toHaveAttribute(
"data-selected",
"true",
);
});
test("Word searches styled runs, navigates real headings, collapses its outline and restores scroll and zoom", async ({
page,
}) => {
await setup(page);
const panel = await open(page, names[1]!);
await expect(panel.locator("section.docx")).toHaveCount(3);
const outline = panel.getByRole("navigation", { name: "Document outline" });
await expect(outline.getByRole("button")).toHaveCount(3);
await find(panel).fill("Search target");
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("1 / 3");
await expect(panel.locator("mark[data-office-match]")).toHaveCount(6);
await find(panel).press("Shift+Enter");
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("3 / 3");
await page.mouse.move(30, 30);
await page.screenshot({
path: path.join(qa, "word-search-light.png"),
animations: "disabled",
});
await find(panel).press("Escape");
await outline
.getByRole("button", { name: "Final review page", exact: true })
.click();
await expect(panel.locator(".tabs")).toContainText("3 / 3");
await panel.getByRole("button", { name: "Zoom in", exact: true }).click();
const zoom = await panel.locator(".bar .muted").last().textContent();
const scroll = await panel
.locator(".canvas")
.evaluate((node) => node.scrollTop);
await page.screenshot({
path: path.join(qa, "word-outline-light.png"),
animations: "disabled",
});
await panel
.getByRole("button", { name: "Toggle document outline", exact: true })
.click();
await expect(outline).toBeHidden();
await switchFile(page, names[0]!);
await expect(panel.getByRole("table", { name: "Navigation" })).toBeVisible();
await switchFile(page, names[1]!);
await expect(panel.locator("section.docx")).toHaveCount(3);
await expect(panel.locator(".bar .muted").last()).toHaveText(zoom!);
await expect(panel.locator(".tabs")).toContainText("3 / 3");
await expect(outline).toBeHidden();
await expect
.poll(() => panel.locator(".canvas").evaluate((node) => node.scrollTop))
.toBeCloseTo(scroll, -1);
});
test("PPT search changes slide, keeps thumbnails sandboxed and restores page, zoom and navigation visibility", async ({
page,
}) => {
await setup(page);
const panel = await open(page, names[2]!);
await expect(panel.locator("iframe.slide")).toBeVisible();
const thumbnails = panel.getByRole("navigation", {
name: "Slide thumbnails",
});
await expect(thumbnails.getByRole("button")).toHaveCount(2);
await expect(thumbnails.locator("iframe").first()).toHaveAttribute(
"sandbox",
"",
);
await panel.getByRole("button", { name: "Slide 2", exact: true }).click();
const slideText = panel.frameLocator("iframe.slide").locator("body");
// Compare actual glyph positions, including text after the highlighted word.
// Converter text boxes use flex: inserting marks must not create extra items.
const glyphPositions = () =>
slideText.evaluate((body) => {
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const positions: { text: string; x: number; y: number }[] = [];
let node;
while ((node = walker.nextNode())) {
if (node.parentElement?.closest("style,script")) continue;
for (let index = 0; index < node.textContent!.length; index++) {
const text = node.textContent![index]!;
if (!text.trim()) continue;
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + 1);
const { x, y } = range.getBoundingClientRect();
positions.push({ text, x, y });
}
}
return positions;
});
await expect(slideText).toContainText(/milestone/i);
const unhighlighted = await glyphPositions();
await find(panel).fill("milestone");
await expect(
panel.getByRole("status", { name: "Search matches" }),
).toHaveText("1 / 1");
await expect(panel.locator(".tabs")).toContainText("2 / 2");
await expect(
panel.frameLocator("iframe.slide").locator("mark[data-current=true]"),
).toContainText(/milestone/i);
const highlighted = await glyphPositions();
expect(highlighted.map(({ text }) => text)).toEqual(
unhighlighted.map(({ text }) => text),
);
for (let index = 0; index < unhighlighted.length; index++) {
expect(highlighted[index]!.x).toBeCloseTo(unhighlighted[index]!.x, 0);
expect(highlighted[index]!.y).toBeCloseTo(unhighlighted[index]!.y, 0);
}
await panel.getByRole("button", { name: "Zoom in", exact: true }).click();
const zoom = await panel.locator(".bar .muted").last().textContent();
await page.screenshot({
path: path.join(qa, "ppt-search-thumbnails-light.png"),
animations: "disabled",
});
await panel
.getByRole("button", { name: "Toggle slide thumbnails", exact: true })
.click();
await expect(thumbnails).toBeHidden();
await switchFile(page, names[0]!);
await expect(panel.getByRole("table", { name: "Navigation" })).toBeVisible();
await switchFile(page, names[2]!);
await expect(panel.locator(".tabs")).toContainText("2 / 2");
await expect(panel.locator(".bar .muted").last()).toHaveText(zoom!);
await expect(thumbnails).toBeHidden();
});

View File

@ -0,0 +1,206 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
import { mockOfficePreview, officeFixtures } from "./utils/office-preview";
const threadId = "00000000-0000-0000-0000-000000009811";
async function openFile(page: Page, name: string) {
const filepath = `/mnt/user-data/outputs/${name}`;
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
title: "Navigation review",
artifacts: [filepath],
messages: [
{ type: "human", id: "request", content: "Review this file" },
{
type: "ai",
id: "result",
content: "Ready to review.",
tool_calls: [
{
id: "present",
name: "present_files",
args: { filepaths: [filepath] },
},
],
},
],
},
],
});
await mockOfficePreview(page);
await page.route(`**/api/threads/${threadId}/artifacts/**`, async (route) =>
route.fulfill({
body: await readFile(path.join(officeFixtures, name)),
contentType: "application/octet-stream",
}),
);
await page.goto(`/workspace/chats/${threadId}`);
await page
.getByRole("button", {
name: `${name} ${name.endsWith("xlsx") ? "Excel" : "Word"}`,
exact: true,
})
.click();
return page.locator("#artifacts");
}
test("spreadsheet keyboard navigation skips hidden cells and merged ranges, remembers selection and copies values", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const panel = await openFile(page, "navigation.xlsx");
await expect(panel.getByRole("table", { name: "Navigation" })).toBeVisible();
await expect(panel.locator('td[tabindex="0"]')).toHaveCount(1);
await panel.locator('td[data-address="A1"]').focus();
await page.keyboard.press("ArrowRight");
await expect(panel.locator('td[data-address="C1"]')).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(panel.locator('td[data-address="C3"]')).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(panel.locator('td[data-address="E3"]')).toBeFocused();
await page.keyboard.press("ArrowLeft");
await expect(panel.locator('td[data-address="C3"]')).toBeFocused();
await panel
.getByRole("textbox", { name: "Go to cell", exact: true })
.fill("D3");
await panel
.getByRole("button", { name: "Go to cell address", exact: true })
.click();
await expect(panel.locator('td[data-address="C3"]')).toBeFocused();
await panel.locator('td[data-address="C4"]').focus();
await page.keyboard.press("ArrowDown");
const merged = panel.locator('td[data-address="A5"]');
await expect(merged).toBeFocused();
await expect(merged).toHaveAttribute("rowspan", "2");
await expect(merged).toHaveAttribute("colspan", "2");
await page.keyboard.press("ArrowRight");
await expect(panel.locator('td[data-address="D5"]')).toBeFocused();
await page.keyboard.press("ArrowLeft");
await expect(merged).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(panel.locator('td[data-address="A8"]')).toBeFocused();
await panel.locator('td[data-address="C3"]').focus();
await panel
.getByRole("button", { name: "Copy cell value", exact: true })
.click();
await expect(
panel.getByRole("status", { name: "Cell action status" }),
).toHaveText("Copied");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"Merged cell",
);
const tab = panel.getByRole("tab", { name: "Navigation", exact: true });
await tab.focus();
await page.keyboard.press("ArrowRight");
await expect(
panel.getByRole("tab", { name: "Other sheet", exact: true }),
).toBeFocused();
await expect(panel.locator(".formula")).toContainText("Other value");
await page.keyboard.press("Home");
await expect(panel.locator(".formula")).toContainText("C3");
await expect(panel.locator(".formula")).toContainText("Merged cell");
await expect(panel.locator('td[tabindex="0"]')).toHaveCount(1);
});
test("Word page controls follow manual scrolling and fit width follows viewport changes", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 960 });
const panel = await openFile(page, "navigation.docx");
await expect(panel.locator("section.docx")).toHaveCount(3);
const previous = panel.getByRole("button", {
name: "Previous page",
exact: true,
});
const next = panel.getByRole("button", { name: "Next page", exact: true });
await expect(previous).toBeDisabled();
await next.click();
await expect(panel.locator(".tabs")).toContainText("2 / 3");
await panel.locator(".canvas").evaluate((canvas) => {
canvas.scrollTop = canvas.scrollHeight;
});
await expect(panel.locator(".tabs")).toContainText("3 / 3");
await expect(next).toBeDisabled();
await previous.click();
await expect(panel.locator(".tabs")).toContainText("2 / 3");
const oldZoom = await panel.locator(".bar .muted").last().textContent();
await page.setViewportSize({ width: 900, height: 960 });
await expect(panel.locator(".bar .muted").last()).not.toHaveText(oldZoom!);
await expect(
panel.getByRole("button", { name: "Fit width", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await panel.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect(
panel.getByRole("button", { name: "Fit width", exact: true }),
).toHaveAttribute("aria-pressed", "false");
});
test("Word preview and outline work without secure-context crypto", async ({
page,
}) => {
await page.addInitScript(() => {
Object.defineProperty(globalThis, "crypto", {
value: {},
configurable: true,
});
});
const panel = await openFile(page, "navigation.docx");
await expect(panel.locator("section.docx")).toHaveCount(3);
await expect(
panel
.getByRole("navigation", { name: "Document outline" })
.getByRole("button"),
).toHaveCount(3);
});
test("Excel pending search supports Enter and Escape", async ({ page }) => {
const panel = await openFile(page, "chart-only.xlsx");
const find = panel.getByRole("searchbox", { name: "Find in file" });
const matches = panel.getByRole("status", { name: "Search matches" });
await expect(panel.getByRole("table", { name: "Summary" })).toBeVisible();
await find.fill("Design");
await find.press("Enter");
await expect(matches).toHaveText("1 / 2");
await find.fill("Documentation");
await find.press("Escape");
await expect(matches).toHaveText("Find text");
await expect(panel.locator("mark[data-office-match]")).toHaveCount(0);
});
test("chart-only Excel drawings do not prevent cell previews or cross-sheet search", async ({
page,
}) => {
const panel = await openFile(page, "chart-only.xlsx");
await expect(panel.getByRole("table", { name: "Summary" })).toBeVisible();
await expect(panel.locator('td[data-address="B2"]')).toHaveText("8");
await panel.getByRole("searchbox", { name: "Find in file" }).fill("Design");
const matches = panel.getByRole("status", { name: "Search matches" });
await expect(matches).toHaveText("1 / 2");
await panel.getByRole("button", { name: "Next match", exact: true }).click();
await expect(matches).toHaveText("2 / 2");
await expect(
panel.getByRole("tab", { name: "Design", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(panel.locator('td[data-address="A1"]')).toHaveText(
"Design review",
);
await expect(panel.locator('td[data-address="A2"]')).toHaveText(
"Documentation",
);
const downloadPromise = page.waitForEvent("download");
await panel
.getByRole("button", { name: "Download chart-only.xlsx", exact: true })
.click();
const download = await downloadPromise;
expect(await readFile(await download.path())).toEqual(
await readFile(path.join(officeFixtures, "chart-only.xlsx")),
);
});

View File

@ -0,0 +1,494 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { createRequire } from "node:module";
import path from "node:path";
import { promisify } from "node:util";
import {
expect,
test as base,
type Page,
type TestInfo,
} from "@playwright/test";
import { officeFixtures, officePackage } from "./utils/office-preview";
interface Zip {
file(name: string, value: string): Zip;
generateAsync(options: {
type: "nodebuffer";
compression: "STORE";
}): Promise<Buffer>;
}
const JSZip = createRequire(
path.resolve("../examples/deerflow-extension-office-preview/package.json"),
)("jszip") as { loadAsync(bytes: Buffer): Promise<Zip> };
let browserPid = 0;
const test = base.extend({
browser: [
async ({ playwright }, use) => {
const server = await playwright.chromium.launchServer({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: ["--enable-precise-memory-info"],
});
browserPid = server.process().pid ?? 0;
const browser = await playwright.chromium.connect(server.wsEndpoint());
try {
await use(browser);
} finally {
await browser.close();
await server.close();
}
},
{ scope: "worker" },
],
});
test.describe.configure({ mode: "default" });
test.skip(
process.platform !== "linux",
"Process-tree RSS measurements use Linux ps.",
);
const samplingTimers = new Set<ReturnType<typeof setInterval>>();
test.afterEach(() => {
for (const timer of samplingTimers) clearInterval(timer);
samplingTimers.clear();
});
let server: Server, origin: string, entry: string, documentBytes: Buffer;
let workerMode = "normal";
test.beforeAll(async () => {
const manifest = JSON.parse(
await readFile(path.join(officePackage, "ui_manifest.json"), "utf8"),
) as { entry: string; files: string[] };
entry = `/assets/${manifest.entry}`;
server = createServer((request, response) => {
void (async () => {
const filename = new URL(request.url!, "http://localhost").pathname;
if (filename === "/") {
response.setHeader("Content-Type", "text/html");
response.end(
'<html><body style="height:800px"><button id="cancel">Cancel</button><div id="preview" style="height:750px"></div></body></html>',
);
} else if (filename === "/document") response.end(documentBytes);
else if (
filename.startsWith("/assets/") &&
manifest.files.includes(filename.slice(8))
) {
response.setHeader("Content-Type", "text/javascript");
response.setHeader("Content-Security-Policy", "sandbox"); // Real Gateway asset policy.
response.end(
filename.endsWith("/office-worker.mjs") && workerMode === "stalled"
? "while(true) {}"
: (filename.endsWith("/office-worker.mjs") &&
workerMode.startsWith("phase-")
? `(() => { const post = self.postMessage.bind(self); self.postMessage = (data, ...args) => { post(data, ...args); if (data.phase === ${JSON.stringify(workerMode.slice(6))}) while (true) {} }; })();\n`
: "") +
(await readFile(
path.join(officePackage, filename.slice(8)),
"utf8",
)),
);
} else {
response.statusCode = 404;
response.end();
}
})().catch(() => {
response.statusCode = 500;
response.end();
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string")
throw new Error("No test server address");
origin = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await new Promise<void>((resolve) => server?.close(() => resolve()));
});
test.beforeEach(() => {
workerMode = "normal";
});
async function mount(page: Page, filename: string, shortenDeadline = false) {
await page.goto(origin);
await page.evaluate(
async ({ entry, filename, shortenDeadline }) => {
const root = document.getElementById("preview")!;
const probe = {
terminated: 0,
created: 0,
maxGap: 0,
longTasks: [] as number[],
phases: [] as string[],
};
Object.assign(window, { officeProbe: probe });
let last = performance.now();
setInterval(() => {
const now = performance.now();
probe.maxGap = Math.max(probe.maxGap, now - last);
last = now;
}, 16);
new PerformanceObserver((list) =>
probe.longTasks.push(...list.getEntries().map((task) => task.duration)),
).observe({ entryTypes: ["longtask"] });
const NativeWorker = window.Worker;
window.Worker = class extends NativeWorker {
constructor(url: string | URL, options?: WorkerOptions) {
super(url, options);
probe.created++;
this.addEventListener("message", ({ data }) => {
if (typeof data.phase === "string") probe.phases.push(data.phase);
});
}
terminate() {
probe.terminated++;
super.terminate();
}
};
if (shortenDeadline) {
const original = window.setTimeout.bind(window);
window.setTimeout = ((
handler: TimerHandler,
delay?: number,
...args: unknown[]
) =>
original(
handler,
delay === 15000 ? 1000 : delay,
...args,
)) as typeof window.setTimeout;
}
const contribution = (await import(entry)).default;
const mounted = contribution.artifactPreviews[0].mount(root, {
filename,
locale: "en-US",
signal: new AbortController().signal,
readFile: async () => (await fetch("/document")).arrayBuffer(),
onError: () => {
root.textContent = "Preview failed safely";
root.dataset.failed = "true";
},
});
document.getElementById("cancel")!.onclick = () => {
mounted.dispose();
root.dataset.cancelled = "true";
};
},
{ entry, filename, shortenDeadline },
);
}
type Probe = {
maxGap: number;
longTasks: number[];
terminated: number;
created: number;
phases: string[];
};
// Browser process-tree RSS includes worker isolates, native parsing/image memory and UI.
async function measure(page: Page, info: TestInfo) {
const cdp = await page.context().newCDPSession(page);
let baseline = 0,
peakRss = 0,
peakUiHeap = 0,
sampling = false;
const sample = async () => {
if (sampling) return;
sampling = true;
try {
const { stdout } = await promisify(execFile)("ps", [
"-eo",
"pid=,ppid=,rss=",
]);
const processes = stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/).map(Number));
const descendants = new Set([browserPid]);
let previous = -1;
while (previous !== descendants.size) {
previous = descendants.size;
for (const [pid, parent] of processes)
if (descendants.has(parent!)) descendants.add(pid!);
}
const rss = processes.reduce(
(sum, [pid, , size]) =>
sum + (descendants.has(pid!) ? size! * 1024 : 0),
0,
);
baseline ||= rss;
peakRss = Math.max(peakRss, rss);
peakUiHeap = Math.max(
peakUiHeap,
(await cdp.send("Runtime.getHeapUsage")).usedSize,
);
} finally {
sampling = false;
}
};
await sample();
const pending = new Set<Promise<void>>();
const timer = setInterval(() => {
const promise = sample()
.catch(() => undefined)
.finally(() => pending.delete(promise));
pending.add(promise);
}, 100);
samplingTimers.add(timer);
return async () => {
clearInterval(timer);
samplingTimers.delete(timer);
await Promise.all(pending);
await sample();
const probe = await page.evaluate(
() => (window as unknown as { officeProbe: Probe }).officeProbe,
);
const measurements = {
peakBrowserRssBytes: peakRss,
browserRssIncreaseBytes: peakRss - baseline,
peakUiHeapBytes: peakUiHeap,
...probe,
};
await info.attach("office-resource-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
console.log(info.title, measurements);
expect(probe.created).toBe(1);
expect(probe.terminated).toBe(1);
expect(probe.maxGap).toBeLessThan(1000);
expect(peakRss).toBeGreaterThan(0);
expect(peakRss - baseline).toBeLessThan(384 * 1024 * 1024);
};
}
for (const [kind, name, xml] of [
["xlsx", "navigation.xlsx", "xl/worksheets/sheet1.xml"],
["docx", "navigation.docx", "word/document.xml"],
["pptx", "project-review.pptx", "ppt/slides/slide1.xml"],
]) {
test(`${kind} rejects complexity within the original archive limits without freezing`, async ({
page,
}, info) => {
const zip = await JSZip.loadAsync(
await readFile(path.join(officeFixtures, name!)),
);
zip.file(xml!, "<root>" + "<n/>".repeat(100001) + "</root>");
documentBytes = await zip.generateAsync({
type: "nodebuffer",
compression: "STORE",
});
expect(documentBytes.length).toBeLessThan(25 * 1024 * 1024);
const done = await measure(page, info);
await mount(page, name!);
await expect(page.locator("#preview")).toHaveAttribute(
"data-failed",
"true",
);
await done();
});
}
test("deadline terminates a synchronous parser loop while the UI remains responsive", async ({
page,
}, info) => {
workerMode = "stalled";
documentBytes = await readFile(path.join(officeFixtures, "navigation.xlsx"));
const done = await measure(page, info);
await mount(page, "navigation.xlsx", true);
await expect(page.locator("#preview")).toHaveAttribute("data-failed", "true");
await done();
});
test("disposing an active preview terminates parsing without a stale error", async ({
page,
}, info) => {
workerMode = "stalled";
documentBytes = await readFile(path.join(officeFixtures, "navigation.docx"));
const done = await measure(page, info);
await mount(page, "navigation.docx");
await expect
.poll(() =>
page.evaluate(
() => (window as unknown as { officeProbe: Probe }).officeProbe.created,
),
)
.toBe(1);
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.locator("#preview")).toHaveAttribute(
"data-cancelled",
"true",
);
await expect(page.locator("#preview")).toBeEmpty();
await done();
});
for (const [name, selector] of [
["navigation.xlsx", "table"],
["navigation.docx", "section.docx"],
["project-review.pptx", "iframe.slide"],
]) {
test(`${name} renders through the packaged worker and Gateway asset policy`, async ({
page,
}, info) => {
documentBytes = await readFile(path.join(officeFixtures, name!));
const done = await measure(page, info);
await mount(page, name!);
await expect(page.locator(`#preview ${selector}`).first()).toBeVisible({
timeout: 20000,
});
await done();
});
}
test("a dense workbook within the new budgets renders in interruptible batches", async ({
page,
}, info) => {
const zip = await JSZip.loadAsync(
await readFile(path.join(officeFixtures, "navigation.xlsx")),
);
const column = (value: number): string =>
value <= 26
? String.fromCharCode(64 + value)
: `A${String.fromCharCode(64 + value - 26)}`;
const rows = Array.from(
{ length: 400 },
(_, r) =>
`<row r="${r + 1}">${Array.from({ length: 40 }, (_, c) => `<c r="${column(c + 1)}${r + 1}" t="inlineStr"><is><t>Cell ${r + 1},${c + 1}</t></is></c>`).join("")}</row>`,
).join("");
zip.file(
"xl/worksheets/sheet1.xml",
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${rows}</sheetData></worksheet>`,
);
documentBytes = await zip.generateAsync({
type: "nodebuffer",
compression: "STORE",
});
const done = await measure(page, info);
await mount(page, "dense.xlsx");
await expect(page.locator("#preview td")).toHaveCount(16000, {
timeout: 20000,
});
await done();
});
test("a long Word document within the new budgets stays responsive in DOM rendering", async ({
page,
}, info) => {
const zip = await JSZip.loadAsync(
await readFile(path.join(officeFixtures, "navigation.docx")),
);
zip.file(
"word/document.xml",
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${"<w:p><w:r><w:t>Bounded layout paragraph.</w:t></w:r></w:p>".repeat(1000)}<w:sectPr/></w:body></w:document>`,
);
documentBytes = await zip.generateAsync({
type: "nodebuffer",
compression: "STORE",
});
const done = await measure(page, info);
await mount(page, "long.docx");
await expect(page.locator("#preview section.docx p")).toHaveCount(1000, {
timeout: 20000,
});
await page
.getByRole("searchbox", { name: "Find in file", exact: true })
.fill("a");
await expect(page.getByRole("status", { name: "Search matches" })).toHaveText(
"1 / 2000",
);
await expect(page.locator("#preview mark[data-office-match]")).toHaveCount(
2000,
);
await done();
});
for (const [name, selector] of [
["navigation.docx", "section.docx"],
["project-review.pptx", "iframe.slide"],
]) {
test(`${name} renders without invoking an XML parser on the main thread`, async ({
page,
}) => {
documentBytes = await readFile(path.join(officeFixtures, name!));
await page.addInitScript(() => {
const NativeDOMParser = window.DOMParser;
window.DOMParser = class extends NativeDOMParser {
parseFromString(text: string, type: DOMParserSupportedType) {
if (type !== "text/html")
throw new Error("Office XML must be parsed in its worker");
return super.parseFromString(text, type);
}
};
});
await mount(page, name!);
await expect(page.locator(`#preview ${selector}`).first()).toBeVisible({
timeout: 20000,
});
});
}
for (const [kind, name] of [
["docx", "navigation.docx"],
["pptx", "project-review.pptx"],
]) {
for (const action of ["cancel", "deadline"]) {
test(`${kind} ${action} terminates a worker stalled in document conversion`, async ({
page,
}, info) => {
workerMode = `phase-${kind}`;
documentBytes = await readFile(path.join(officeFixtures, name!));
const done = await measure(page, info);
await mount(page, name!, action === "deadline");
await expect
.poll(() =>
page.evaluate(
() =>
(window as unknown as { officeProbe: Probe }).officeProbe.phases,
),
)
.toContain(kind);
if (action === "cancel") {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.locator("#preview")).toBeEmpty();
} else
await expect(page.locator("#preview")).toHaveAttribute(
"data-failed",
"true",
);
await done();
});
}
}
test("worker Word models preserve inline images, headers and footers", async ({
page,
}) => {
documentBytes = await readFile(
path.join(officeFixtures, "worker-resources.docx"),
);
await mount(page, "worker-resources.docx");
await expect(
page.getByText("Worker header text", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Worker footer text", { exact: true }),
).toBeVisible();
const image = page.locator("#preview section.docx img");
await expect(image).toHaveCount(1);
await expect
.poll(() =>
image.evaluate((node) => (node as HTMLImageElement).naturalWidth),
)
.toBe(2);
const url = await image.getAttribute("src");
expect(url?.startsWith("blob:")).toBe(true);
await page.getByRole("button", { name: "Cancel", exact: true }).click();
expect(
await page.evaluate(async (url) => {
try {
await fetch(url!);
return false;
} catch {
return true;
}
}, url),
).toBe(true);
});

Some files were not shown because too many files have changed in this diff Show More