mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
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:
parent
b4410f64d4
commit
81de0761ed
41
.github/workflows/e2e-tests.yml
vendored
41
.github/workflows/e2e-tests.yml
vendored
@ -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
|
||||
|
||||
24
.github/workflows/replay-e2e.yml
vendored
24
.github/workflows/replay-e2e.yml
vendored
@ -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
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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])
|
||||
|
||||
18
docs/agent-service-topology.md
Normal file
18
docs/agent-service-topology.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
35
docs/guide/file-previews.md
Normal file
35
docs/guide/file-previews.md
Normal 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.
|
||||
@ -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).
|
||||
|
||||
2
examples/deerflow-extension-office-preview/.gitattributes
vendored
Normal file
2
examples/deerflow-extension-office-preview/.gitattributes
vendored
Normal 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
|
||||
4
examples/deerflow-extension-office-preview/.gitignore
vendored
Normal file
4
examples/deerflow-extension-office-preview/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
!deerflow_extension_office_preview/static/dist/
|
||||
!deerflow_extension_office_preview/static/dist/**
|
||||
*.egg-info/
|
||||
147
examples/deerflow-extension-office-preview/README.md
Normal file
147
examples/deerflow-extension-office-preview/README.md
Normal 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.
|
||||
8335
examples/deerflow-extension-office-preview/THIRD_PARTY_NOTICES.md
Normal file
8335
examples/deerflow-extension-office-preview/THIRD_PARTY_NOTICES.md
Normal file
File diff suppressed because it is too large
Load Diff
45
examples/deerflow-extension-office-preview/build.mjs
Normal file
45
examples/deerflow-extension-office-preview/build.mjs
Normal 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",
|
||||
);
|
||||
@ -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")
|
||||
@ -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"
|
||||
}
|
||||
1
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/chunk-OVILVMKS.mjs
generated
vendored
Normal file
1
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/chunk-OVILVMKS.mjs
generated
vendored
Normal 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};
|
||||
46
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/docx-preview-VUQTZ67Q.mjs
generated
vendored
Normal file
46
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/docx-preview-VUQTZ67Q.mjs
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
7
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/office-3UIRH4NA.mjs
generated
vendored
Normal file
7
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/office-3UIRH4NA.mjs
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
293
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/office-worker.mjs
generated
vendored
Normal file
293
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/chunks/office-worker.mjs
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/index.mjs
generated
vendored
Normal file
1
examples/deerflow-extension-office-preview/deerflow_extension_office_preview/static/dist/index.mjs
generated
vendored
Normal 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};
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
29
examples/deerflow-extension-office-preview/notices.mjs
Normal file
29
examples/deerflow-extension-office-preview/notices.mjs
Normal 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");
|
||||
3066
examples/deerflow-extension-office-preview/package-lock.json
generated
Normal file
3066
examples/deerflow-extension-office-preview/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
examples/deerflow-extension-office-preview/package.json
Normal file
19
examples/deerflow-extension-office-preview/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
examples/deerflow-extension-office-preview/pyproject.toml
Normal file
21
examples/deerflow-extension-office-preview/pyproject.toml
Normal 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"]
|
||||
@ -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));
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
@ -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()
|
||||
@ -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];
|
||||
}
|
||||
19
examples/deerflow-extension-office-preview/ui/identifiers.js
Normal file
19
examples/deerflow-extension-office-preview/ui/identifiers.js
Normal 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;
|
||||
};
|
||||
}
|
||||
37
examples/deerflow-extension-office-preview/ui/index.js
Normal file
37
examples/deerflow-extension-office-preview/ui/index.js
Normal 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();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
959
examples/deerflow-extension-office-preview/ui/office.js
Normal file
959
examples/deerflow-extension-office-preview/ui/office.js
Normal 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();
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
322
examples/deerflow-extension-office-preview/ui/resource-limits.js
Normal file
322
examples/deerflow-extension-office-preview/ui/resource-limits.js
Normal 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");
|
||||
}
|
||||
140
examples/deerflow-extension-office-preview/ui/search.js
Normal file
140
examples/deerflow-extension-office-preview/ui/search.js
Normal 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;
|
||||
}
|
||||
54
examples/deerflow-extension-office-preview/ui/word-view.js
Normal file
54
examples/deerflow-extension-office-preview/ui/word-view.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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],
|
||||
}));
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
92
examples/deerflow-extension-office-preview/ui/worker-xml.js
Normal file
92
examples/deerflow-extension-office-preview/ui/worker-xml.js
Normal 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 };
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
47
frontend/docs/file-preview-ownership.md
Normal file
47
frontend/docs/file-preview-ownership.md
Normal 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.
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/workspace/file-name.tsx
Normal file
21
frontend/src/components/workspace/file-name.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/workspace/file-type-icon.tsx
Normal file
37
frontend/src/components/workspace/file-type-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/workspace/input-attachment.tsx
Normal file
191
frontend/src/components/workspace/input-attachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
47
frontend/src/core/artifacts/binary-preview.ts
Normal file
47
frontend/src/core/artifacts/binary-preview.ts
Normal 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") };
|
||||
}
|
||||
45
frontend/src/core/artifacts/download.ts
Normal file
45
frontend/src/core/artifacts/download.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
27
frontend/src/core/artifacts/reading-state.ts
Normal file
27
frontend/src/core/artifacts/reading-state.ts
Normal 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. */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
30
frontend/src/core/extensions/artifact-previews.ts
Normal file
30
frontend/src/core/extensions/artifact-previews.ts
Normal 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 };
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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: (
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
195
frontend/src/core/uploads/attachment-upload-session.ts
Normal file
195
frontend/src/core/uploads/attachment-upload-session.ts
Normal 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!);
|
||||
}
|
||||
}
|
||||
@ -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`,
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 ({
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 }),
|
||||
|
||||
225
frontend/tests/e2e/file-transfer.spec.ts
Normal file
225
frontend/tests/e2e/file-transfer.spec.ts
Normal 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);
|
||||
});
|
||||
BIN
frontend/tests/e2e/fixtures/office-preview/chart-only.xlsx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/chart-only.xlsx
Normal file
Binary file not shown.
@ -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"))
|
||||
@ -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")
|
||||
@ -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"))
|
||||
@ -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.
|
||||
BIN
frontend/tests/e2e/fixtures/office-preview/navigation.docx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/navigation.docx
Normal file
Binary file not shown.
BIN
frontend/tests/e2e/fixtures/office-preview/navigation.xlsx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/navigation.xlsx
Normal file
Binary file not shown.
BIN
frontend/tests/e2e/fixtures/office-preview/project-brief.docx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/project-brief.docx
Normal file
Binary file not shown.
@ -0,0 +1,9 @@
|
||||
{
|
||||
"project": "DeerFlow",
|
||||
"status": "ready",
|
||||
"deliverables": 5,
|
||||
"review": {
|
||||
"owner": "Design team",
|
||||
"approved": true
|
||||
}
|
||||
}
|
||||
BIN
frontend/tests/e2e/fixtures/office-preview/project-review.pptx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/project-review.pptx
Normal file
Binary file not shown.
BIN
frontend/tests/e2e/fixtures/office-preview/quarterly-report.xlsx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/quarterly-report.xlsx
Normal file
Binary file not shown.
BIN
frontend/tests/e2e/fixtures/office-preview/worker-resources.docx
Normal file
BIN
frontend/tests/e2e/fixtures/office-preview/worker-resources.docx
Normal file
Binary file not shown.
245
frontend/tests/e2e/office-preview-interactions.spec.ts
Normal file
245
frontend/tests/e2e/office-preview-interactions.spec.ts
Normal 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();
|
||||
});
|
||||
206
frontend/tests/e2e/office-preview-navigation.spec.ts
Normal file
206
frontend/tests/e2e/office-preview-navigation.spec.ts
Normal 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")),
|
||||
);
|
||||
});
|
||||
494
frontend/tests/e2e/office-preview-resources.spec.ts
Normal file
494
frontend/tests/e2e/office-preview-resources.spec.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user