From 459dd78707c7082662b49a4728e95d52d631d26a Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:19:59 +0800 Subject: [PATCH] perf(frontend): bound delivery, bundles, and long-running UI work (#4622) * docs: design frontend performance remediation * docs: plan frontend performance remediation * test(frontend): add route asset performance budgets * perf(nginx): compress textual responses safely * perf(frontend): lazy load case study media * perf(frontend): bound static demo file tracing * perf(frontend): restore static locale boundaries * perf(frontend): defer closed workspace panels * perf(frontend): split editors and deduplicate highlighting * perf(frontend): index incremental message derivation * perf(frontend): stabilize paged history cache policy * perf(frontend): bound streaming markdown renders * perf(frontend): virtualize message history * perf(frontend): bound and virtualize chat lists * perf(frontend): suspend inactive decorative animation * perf(browser): stream latest frames as binary * perf(artifacts): stream bounded text previews * docs: finalize performance runtime boundaries * style(backend): apply test formatting * fix(frontend): keep translation functions client-side * perf(frontend): defer decorative animation bundles * test(frontend): lock optimized route budgets * fix: harden frontend performance boundaries * test(frontend): update i18n provider fixture * fix(frontend): preserve sidebar pagination position * style(backend): format artifact range test --- AGENTS.md | 2 + CHANGELOG.md | 11 + README.md | 17 + backend/AGENTS.md | 9 +- backend/app/gateway/routers/artifacts.py | 115 ++- backend/app/gateway/routers/browser.py | 45 +- .../community/browser_automation/session.py | 18 +- .../blocking_io/test_artifacts_router.py | 8 +- backend/tests/test_artifacts_router.py | 75 +- backend/tests/test_browser_automation.py | 16 +- backend/tests/test_browser_router.py | 66 +- backend/tests/test_nginx_compression.py | 32 + docker/nginx/nginx.conf | 17 +- docker/nginx/nginx.local.conf | 15 + ...-frontend-performance-baseline-delivery.md | 84 ++ ...7-31-frontend-performance-bundle-static.md | 102 +++ ...07-31-frontend-performance-chat-runtime.md | 95 +++ ...-31-frontend-performance-live-artifacts.md | 106 +++ ...frontend-performance-remediation-design.md | 317 ++++++++ frontend/AGENTS.md | 27 + frontend/package.json | 2 + frontend/performance-budgets.json | 11 + frontend/pnpm-lock.yaml | 3 + frontend/scripts/measure-route-assets.mjs | 249 ++++++ frontend/src/app/(auth)/layout.tsx | 18 +- frontend/src/app/[lang]/docs/layout.tsx | 2 + frontend/src/app/blog/layout.tsx | 16 +- frontend/src/app/layout.tsx | 16 +- .../artifacts/[[...artifact_path]]/route.ts | 73 +- .../api/threads/[thread_id]/history/route.ts | 20 +- .../src/app/mock/api/threads/search/route.ts | 62 +- frontend/src/app/page.tsx | 3 +- frontend/src/app/workspace/chats/page.tsx | 58 +- frontend/src/app/workspace/layout.tsx | 16 +- frontend/src/app/workspace/page.tsx | 13 +- .../src/components/ai-elements/code-block.tsx | 84 +- .../components/ai-elements/shiki-highlight.ts | 75 ++ frontend/src/components/landing/header.tsx | 4 +- frontend/src/components/landing/hero.tsx | 33 +- .../landing/sections/case-study-section.tsx | 19 +- .../landing/sections/skills-section.tsx | 17 +- .../landing/sections/whats-new-section.tsx | 105 ++- .../artifacts/artifact-file-detail.tsx | 197 +++-- .../components/workspace/browser-view/api.ts | 6 +- .../workspace/browser-view/frame-buffer.ts | 63 ++ .../browser-view/use-browser-stream.ts | 58 +- .../components/workspace/chats/chat-box.tsx | 48 +- .../workspace/code-editor-extensions.ts | 101 +++ .../src/components/workspace/code-editor.tsx | 65 +- .../workspace/messages/markdown-content.tsx | 107 ++- .../workspace/messages/message-group.tsx | 26 +- .../workspace/messages/message-list.tsx | 723 ++++++++---------- .../messages/virtual-message-list.tsx | 183 +++++ .../components/workspace/recent-chat-list.tsx | 282 +++---- .../settings/settings-dialog-host.tsx | 18 +- .../workspace/settings/settings-dialog.tsx | 76 +- .../workspace/thread-list-virtualizer.tsx | 102 +++ frontend/src/core/artifacts/hooks.ts | 22 +- frontend/src/core/artifacts/loader.ts | 65 +- frontend/src/core/dom/render-activity.ts | 103 +++ frontend/src/core/i18n/client-translations.ts | 12 + frontend/src/core/i18n/context.tsx | 28 +- frontend/src/core/i18n/hooks.ts | 12 +- frontend/src/core/i18n/locales/en-US.ts | 11 + frontend/src/core/i18n/locales/types.ts | 7 + frontend/src/core/i18n/locales/zh-CN.ts | 10 + frontend/src/core/i18n/server.ts | 6 +- frontend/src/core/i18n/translations.ts | 12 +- frontend/src/core/messages/derived-state.ts | 228 ++++++ frontend/src/core/threads/hooks.ts | 6 + frontend/src/core/threads/static-demo.ts | 106 +++ .../src/core/threads/thread-list-model.ts | 43 ++ frontend/src/styles/globals.css | 4 + frontend/tests/e2e/artifact-preview.spec.ts | 60 +- frontend/tests/e2e/thread-history.spec.ts | 90 ++- .../tests/unit/app/layout-boundaries.test.ts | 45 ++ .../app/mock/static-artifact-route.test.ts | 85 ++ .../ai-elements/code-block.dom.test.tsx | 18 + .../components/ai-elements/code-block.test.ts | 57 ++ .../auth/remember-session-option.test.ts | 2 + .../landing/case-study-section.dom.test.tsx | 22 + .../ui/animation-scheduling.test.ts | 56 ++ .../artifacts/large-preview-source.test.ts | 23 + .../browser-stream-source.test.ts | 25 + .../browser-view/frame-buffer.dom.test.ts | 68 ++ .../citations/citation-sources-panel.test.ts | 3 + .../workspace/code-editor-extensions.test.ts | 24 + .../components/workspace/lazy-panels.test.ts | 40 + .../messages/human-input-card.dom.test.tsx | 5 +- .../messages/human-input-card.test.ts | 2 + .../messages/markdown-content.dom.test.tsx | 41 +- .../workspace/messages/message-group.test.ts | 2 + .../messages/virtual-message-list.test.ts | 22 + .../workspace/thread-list-virtualizer.test.ts | 14 + .../tests/unit/core/artifacts/loader.test.ts | 117 ++- .../unit/core/dom/render-activity.dom.test.ts | 137 ++++ .../tests/unit/core/i18n/context.dom.test.tsx | 44 ++ .../tests/unit/core/i18n/translations.test.ts | 12 +- .../unit/core/messages/derived-state.test.ts | 155 ++++ .../threads/local-turn-order.dom.test.tsx | 2 + .../unit/core/threads/static-demo.test.ts | 67 ++ .../unit/core/threads/stream-throttle.test.ts | 2 + .../threads/thread-history-options.test.ts | 12 + .../core/threads/thread-list-model.test.ts | 74 ++ .../unit/scripts/measure-route-assets.test.ts | 69 ++ 105 files changed, 5148 insertions(+), 1053 deletions(-) create mode 100644 backend/tests/test_nginx_compression.py create mode 100644 docs/superpowers/plans/2026-07-31-frontend-performance-baseline-delivery.md create mode 100644 docs/superpowers/plans/2026-07-31-frontend-performance-bundle-static.md create mode 100644 docs/superpowers/plans/2026-07-31-frontend-performance-chat-runtime.md create mode 100644 docs/superpowers/plans/2026-07-31-frontend-performance-live-artifacts.md create mode 100644 docs/superpowers/specs/2026-07-31-frontend-performance-remediation-design.md create mode 100644 frontend/performance-budgets.json create mode 100644 frontend/scripts/measure-route-assets.mjs create mode 100644 frontend/src/components/ai-elements/shiki-highlight.ts create mode 100644 frontend/src/components/workspace/browser-view/frame-buffer.ts create mode 100644 frontend/src/components/workspace/code-editor-extensions.ts create mode 100644 frontend/src/components/workspace/messages/virtual-message-list.tsx create mode 100644 frontend/src/components/workspace/thread-list-virtualizer.tsx create mode 100644 frontend/src/core/dom/render-activity.ts create mode 100644 frontend/src/core/i18n/client-translations.ts create mode 100644 frontend/src/core/messages/derived-state.ts create mode 100644 frontend/src/core/threads/thread-list-model.ts create mode 100644 frontend/tests/unit/app/layout-boundaries.test.ts create mode 100644 frontend/tests/unit/app/mock/static-artifact-route.test.ts create mode 100644 frontend/tests/unit/components/ai-elements/code-block.dom.test.tsx create mode 100644 frontend/tests/unit/components/ai-elements/code-block.test.ts create mode 100644 frontend/tests/unit/components/landing/case-study-section.dom.test.tsx create mode 100644 frontend/tests/unit/components/ui/animation-scheduling.test.ts create mode 100644 frontend/tests/unit/components/workspace/artifacts/large-preview-source.test.ts create mode 100644 frontend/tests/unit/components/workspace/browser-view/browser-stream-source.test.ts create mode 100644 frontend/tests/unit/components/workspace/browser-view/frame-buffer.dom.test.ts create mode 100644 frontend/tests/unit/components/workspace/code-editor-extensions.test.ts create mode 100644 frontend/tests/unit/components/workspace/lazy-panels.test.ts create mode 100644 frontend/tests/unit/components/workspace/messages/virtual-message-list.test.ts create mode 100644 frontend/tests/unit/components/workspace/thread-list-virtualizer.test.ts create mode 100644 frontend/tests/unit/core/dom/render-activity.dom.test.ts create mode 100644 frontend/tests/unit/core/i18n/context.dom.test.tsx create mode 100644 frontend/tests/unit/core/messages/derived-state.test.ts create mode 100644 frontend/tests/unit/core/threads/static-demo.test.ts create mode 100644 frontend/tests/unit/core/threads/thread-history-options.test.ts create mode 100644 frontend/tests/unit/core/threads/thread-list-model.test.ts create mode 100644 frontend/tests/unit/scripts/measure-route-assets.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1580d4c70..8533c8336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,8 @@ Nginx is the single public entry: it serves the frontend and proxies `/api/langg to the Gateway's LangGraph runtime, rewriting it to Gateway's native `/api/*` routes; all other `/api/*` go straight to the Gateway REST routers. See [backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail. +It compresses HTML and configured textual assets, while deliberately leaving SSE, +fonts, images, audio, and video uncompressed at the proxy layer. Both compose files publish that entry as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` — **loopback by default**, matching the README's documented deployment model. A bare diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4a4cce9..18ecf68e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -222,6 +222,17 @@ This section accumulates work toward the **2.1.0** milestone ### Changed +- **frontend performance:** Keep the public root and localized docs static; + lazy-load closed workspace panels and editor/highlighter dependencies; + incrementally derive streamed message state; bound streaming Markdown work; + virtualize long message and chat lists; pause offscreen decorative effects; + and enforce representative route JS/CSS budgets. +- **browser:** Negotiate binary Browser Live JPEG frames, retain the legacy + JSON/base64 protocol for older clients, coalesce presentation to the latest + frame per refresh, and revoke replaced object URLs. +- **artifacts:** Stream regular text artifacts with HTTP byte-range support and + limit the initial Web UI preview to 1 MiB until the user explicitly loads the + complete file. - **sandbox:** The Helm chart now defaults per-sandbox Services to `ClusterIP` instead of `NodePort`, so the code-execution sandbox is reachable only inside the cluster via Service DNS (`http://sandbox--svc..svc.cluster.local`) diff --git a/README.md b/README.md index fe4147904..e2bcaff27 100644 --- a/README.md +++ b/README.md @@ -945,6 +945,12 @@ After each run, DeerFlow records a workspace change summary for the run-owned `w Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. +Text artifacts are streamed with HTTP byte-range support. The Web UI initially +loads at most 1 MiB, shows the preview size when a file is larger, and waits for +an explicit **Load full file** action before fetching the remainder or mounting +the full code editor. Active HTML, XHTML, and SVG artifacts remain forced +downloads at the Gateway boundary. + With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. `AioSandboxProvider` normally detects thread-data mounts from its backend: local @@ -979,6 +985,11 @@ uv run playwright install chromium Then uncomment the `group: browser` tool entries in `config.yaml` (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close`). `make dev` / Docker startup detects an enabled `browser_navigate` tool and preserves the `browser` extra on dependency syncs. The Gateway fails startup if browser control is configured but Playwright is missing, and `/api/features` hides the Browser UI unless the backend can actually serve it. Keep `headless: true` and `allow_private_addresses: false` for anything but local, trusted debugging. Attaching to an existing Chrome with `cdp_url` cannot enforce DeerFlow's subresource/redirect SSRF guard and therefore fails closed unless `allow_unguarded_cdp: true` explicitly acknowledges that risk; use it only with a trusted local browser. Browser sessions are process-local; keep `GATEWAY_WORKERS=1` while this tool group is enabled because ordinary uvicorn worker dispatch does not provide thread affinity. +The workspace Browser Live client negotiates binary JPEG WebSocket frames, +keeps only the newest pending frame per display refresh, and revokes replaced +object URLs. Gateway control messages remain JSON, and clients that do not +request the binary capability retain the legacy JSON/base64 frame protocol. + ### Context Engineering **Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents. @@ -1200,6 +1211,12 @@ The JSON includes compact review records with `priority`, `location`, `blocking_call`, `event_loop_exposure`, `reason`, and `code`. Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts. +Frontend route asset budgets can be checked with `cd frontend && pnpm +perf:check`. The command measures `/login` from a normal production build, then +performs a production static-demo build for the fixture-backed workspace routes. +It measures the unique JavaScript and CSS referenced by representative routes +and writes the detailed result to `.next/performance-results.json`. + ## License This project is open source and available under the [MIT License](./LICENSE). diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 5a6288ea4..d652a1401 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -309,6 +309,13 @@ Agentic browser sessions are process-local. The Gateway startup safety gate reje uvicorn worker dispatch does not provide thread affinity for browser tools, REST navigation, and the Live WebSocket. +Browser Live screenshots remain JPEG bytes inside the harness and the Gateway's +bounded, drop-oldest frame queue. WebSocket clients that request +`frame_format=binary` receive binary messages; control metadata remains JSON. +The legacy no-parameter protocol still base64-encodes frames into JSON at the +Gateway boundary for backward compatibility. Unknown `frame_format` values +receive a JSON error and close code 1008. + ## Architecture ### Harness / App Split @@ -495,7 +502,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores ` | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail | -| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update explicitly. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). | +| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update explicitly. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). | | **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | | **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, an empty run-event message feed is seeded from an existing checkpoint head so legacy checkpoint-only history receives earlier thread-global sequence numbers and remains visible after the new run; a thread with no checkpoint or an already-populated feed skips this compatibility path. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its configured `context_window`. | diff --git a/backend/app/gateway/routers/artifacts.py b/backend/app/gateway/routers/artifacts.py index b15c5c781..08f12537f 100644 --- a/backend/app/gateway/routers/artifacts.py +++ b/backend/app/gateway/routers/artifacts.py @@ -12,7 +12,7 @@ from pathlib import Path from urllib.parse import quote from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import FileResponse, PlainTextResponse, Response +from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from app.gateway.authz import require_permission @@ -159,6 +159,51 @@ def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | Non return headers +def _slice_byte_range(content: bytes, range_header: str | None) -> tuple[bytes, int, dict[str, str]]: + """Apply one RFC 9110 byte range to an in-memory archive member.""" + size = len(content) + headers = {"Accept-Ranges": "bytes"} + if range_header is None: + return content, 200, headers + + def unsatisfied() -> HTTPException: + return HTTPException( + status_code=416, + detail="Requested range is not satisfiable", + headers={"Accept-Ranges": "bytes", "Content-Range": f"bytes */{size}"}, + ) + + if not range_header.startswith("bytes=") or "," in range_header: + raise unsatisfied() + range_spec = range_header.removeprefix("bytes=") + if "-" not in range_spec: + raise unsatisfied() + start_text, end_text = range_spec.split("-", 1) + try: + if start_text: + start = int(start_text) + end = size - 1 if not end_text else min(int(end_text), size - 1) + else: + suffix_length = int(end_text) + if suffix_length <= 0: + raise unsatisfied() + start = max(size - suffix_length, 0) + end = size - 1 + except ValueError as exc: + raise unsatisfied() from exc + + if size == 0 or start < 0 or start >= size or end < start: + raise unsatisfied() + ranged_content = content[start : end + 1] + headers.update( + { + "Content-Range": f"bytes {start}-{end}/{size}", + "Content-Length": str(len(ranged_content)), + } + ) + return ranged_content, 206, headers + + def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: """Check if file is text by examining content for null bytes.""" try: @@ -239,18 +284,14 @@ def _load_skill_archive_member(actual_skill_path: Path, skill_file_path: str, in return content, mime_type -def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tuple[str, str | None, bytes | str | None]: +def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tuple[str, str | None]: """Worker-thread body for the regular branch of ``get_artifact``. - Stat probes, MIME sniffing (``mimetypes`` lazily stats the system MIME - database on first use), and text reads are blocking filesystem IO. Returns - a ``(kind, mime_type, payload)`` plan the handler turns into a response on - the loop: ``("file", mime, None)`` (attachment / forced-download active - content, streamed by ``FileResponse``), ``("inline_file", mime, None)`` - (inline binary preview — also streamed by ``FileResponse`` so the client - can issue byte-``Range`` requests, e.g. to seek within audio/video - artifacts instead of always replaying from byte 0), or ``("text", mime, - str)``. Behavior/error codes match the previous inline logic. + Stat probes and MIME sniffing (``mimetypes`` lazily stats the system MIME + database on first use) are blocking filesystem IO. Returns a + ``(kind, mime_type)`` plan the handler turns into a streamed + ``FileResponse``. Inline text and binary previews both use FileResponse so + clients can request a bounded byte range instead of buffering a whole file. """ if not actual_path.exists(): raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") @@ -259,15 +300,12 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl mime_type, _ = mimetypes.guess_type(actual_path) # Active content / explicit download is streamed by FileResponse — no read here. if download or mime_type in ACTIVE_CONTENT_MIME_TYPES: - return ("file", mime_type, None) + return ("file", mime_type) if mime_type and mime_type.startswith("text/"): - return ("text", mime_type, actual_path.read_text(encoding="utf-8")) + return ("inline_file", mime_type) if is_text_file_by_content(actual_path): - return ("text", mime_type, actual_path.read_text(encoding="utf-8")) - # Binary inline preview (images, audio, video, PDFs, ...): stream via - # FileResponse instead of buffering the whole file in memory, so it also - # gets FileResponse's built-in byte-Range handling (see get_artifact). - return ("inline_file", mime_type, None) + return ("inline_file", mime_type or "text/plain") + return ("inline_file", mime_type) @router.get( @@ -338,23 +376,37 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa if download or mime_type in ACTIVE_CONTENT_MIME_TYPES: return Response(content=content, media_type=mime_type or "application/octet-stream", headers=_build_attachment_headers(download_name, cache_headers)) + # Archive members are already bounded during extraction. Preserve byte + # semantics here so the frontend can request only its preview budget, + # including a final partial UTF-8 sequence. + request_headers = request.headers if request is not None else {} + range_header = None if request_headers.get("if-range") else request_headers.get("range") + ranged_content, status_code, range_headers = _slice_byte_range(content, range_header) + inline_headers = {**cache_headers, **range_headers} + if mime_type and mime_type.startswith("text/"): - return PlainTextResponse(content=content.decode("utf-8"), media_type=mime_type, headers=cache_headers) + return Response(content=ranged_content, status_code=status_code, media_type=mime_type, headers=inline_headers) # Default to plain text for unknown types that look like text try: - return PlainTextResponse(content=content.decode("utf-8"), media_type="text/plain", headers=cache_headers) + content.decode("utf-8") + return Response(content=ranged_content, status_code=status_code, media_type="text/plain", headers=inline_headers) except UnicodeDecodeError: - return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers) + return Response( + content=ranged_content, + status_code=status_code, + media_type=mime_type or "application/octet-stream", + headers=inline_headers, + ) actual_path = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, path, user_id=owner_user_id) logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}") - # Offload path stat + MIME sniff + file reads (all blocking filesystem IO). - # Active content and explicit downloads are streamed by FileResponse, so the - # worker only reports the kind; inline text/binary payloads are read in-thread. - kind, mime_type, payload = await asyncio.to_thread(_read_artifact_payload, actual_path, path, download) + # Offload path stat + MIME sniff (blocking filesystem IO). Every regular + # artifact response is streamed by FileResponse; the worker only reports + # disposition and media type. + kind, mime_type = await asyncio.to_thread(_read_artifact_payload, actual_path, path, download) if kind == "file": # Always force download for active content types to prevent script @@ -362,23 +414,14 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers=_build_attachment_headers(actual_path.name)) if kind == "inline_file": - # FileResponse (unlike a fully-buffered Response) honors byte-Range - # requests. Browsers issue these when seeking an