DanielWalnut 459dd78707
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
2026-08-01 22:19:59 +08:00

503 lines
22 KiB
Python

import asyncio
import hashlib
import logging
import mimetypes
import os
import stat
import tempfile
import zipfile
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_run_manager
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.config.paths import make_safe_user_id
from deerflow.runtime import ConflictError, ThreadOperationKind
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["artifacts"])
ACTIVE_CONTENT_MIME_TYPES = {
"text/html",
"application/xhtml+xml",
"image/svg+xml",
}
MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
_SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024
_EDITABLE_OUTPUTS_PREFIX = "mnt/user-data/outputs/"
_ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-"
class ArtifactUpdateRequest(BaseModel):
content: str
expected_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
class ArtifactUpdateResponse(BaseModel):
path: str
sha256: str
size: int
@asynccontextmanager
async def reserve_artifact_write(request: Request, thread_id: str, *, user_id: str) -> AsyncIterator[None]:
"""Serialize an artifact edit against runs and other thread mutations."""
run_manager = get_run_manager(request)
async with run_manager.reserve_thread_operation(
thread_id,
kind=ThreadOperationKind.artifact_write,
user_id=user_id,
):
yield
def _normalize_editable_artifact_path(path: str) -> str:
stripped = path.lstrip("/")
if not stripped.startswith(_EDITABLE_OUTPUTS_PREFIX):
raise HTTPException(status_code=400, detail="Only files in /mnt/user-data/outputs can be edited")
if ".skill/" in stripped or stripped.endswith(".skill"):
raise HTTPException(status_code=415, detail="Skill archives cannot be edited in the artifacts panel")
return f"/{stripped}"
def _load_editable_artifact(actual_path: Path, path: str, expected_sha256: str) -> tuple[bytes, os.stat_result]:
try:
file_stat = os.lstat(actual_path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") from None
if stat.S_ISLNK(file_stat.st_mode):
raise HTTPException(status_code=415, detail="Symlinked artifacts cannot be edited")
if not stat.S_ISREG(file_stat.st_mode):
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
if file_stat.st_size > MAX_EDITABLE_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="Artifact is too large to edit")
current = actual_path.read_bytes()
if len(current) > MAX_EDITABLE_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="Artifact is too large to edit")
if b"\x00" in current:
raise HTTPException(status_code=415, detail="Binary artifacts cannot be edited")
try:
current.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=415, detail="Only UTF-8 text artifacts can be edited") from None
current_sha256 = hashlib.sha256(current).hexdigest()
if current_sha256 != expected_sha256:
raise HTTPException(status_code=412, detail="Artifact changed since it was opened")
return current, file_stat
def _encode_artifact_update(content: str) -> bytes:
encoded = content.encode("utf-8")
if len(encoded) > MAX_EDITABLE_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="Artifact is too large to edit")
if b"\x00" in encoded:
raise HTTPException(status_code=415, detail="Binary content cannot be saved as an artifact")
return encoded
def _replace_artifact_atomically(actual_path: Path, content: bytes, file_stat: os.stat_result) -> None:
temp_fd, temp_path_str = tempfile.mkstemp(prefix=_ARTIFACT_EDIT_TEMP_PREFIX, dir=actual_path.parent)
temp_path = Path(temp_path_str)
try:
# Preserve ownership where possible and keep replacement permissions
# scoped to the owner/group. The shared outputs directory allows a
# mounted sandbox to reach the file without making it world-writable.
if hasattr(os, "fchown"):
try:
os.fchown(temp_fd, file_stat.st_uid, file_stat.st_gid)
except OSError:
logger.debug("Could not preserve artifact ownership: %s", actual_path, exc_info=True)
# Windows has no fchmod and uses ACLs rather than POSIX mode bits.
# Keep the mkstemp permissions there; retain the existing POSIX
# behavior on platforms that expose descriptor-based chmod.
if hasattr(os, "fchmod"):
os.fchmod(temp_fd, stat.S_IMODE(file_stat.st_mode) | 0o660)
with os.fdopen(temp_fd, "wb") as handle:
temp_fd = -1
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, actual_path)
finally:
if temp_fd >= 0:
os.close(temp_fd)
try:
temp_path.unlink()
except FileNotFoundError:
pass
def _sync_artifact_to_sandbox(sandbox, virtual_path: str, content: bytes) -> None:
sandbox.update_file(virtual_path, content)
def _build_content_disposition(disposition_type: str, filename: str) -> str:
"""Build an RFC 5987 encoded Content-Disposition header value."""
return f"{disposition_type}; filename*=UTF-8''{quote(filename)}"
def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
headers = {"Content-Disposition": _build_content_disposition("attachment", filename)}
if extra_headers:
headers.update(extra_headers)
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:
with open(path, "rb") as f:
chunk = f.read(sample_size)
# Text files shouldn't contain null bytes
return b"\x00" not in chunk
except Exception:
return False
def _read_skill_archive_member(zip_ref: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
"""Read a .skill archive member while enforcing an uncompressed size cap."""
if info.file_size > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks: list[bytes] = []
total_read = 0
with zip_ref.open(info, "r") as src:
while chunk := src.read(_SKILL_ARCHIVE_READ_CHUNK_SIZE):
total_read += len(chunk)
if total_read > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks.append(chunk)
return b"".join(chunks)
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
"""Extract a file from a .skill ZIP archive.
Args:
zip_path: Path to the .skill file (ZIP archive).
internal_path: Path to the file inside the archive (e.g., "SKILL.md").
Returns:
The file content as bytes, or None if not found.
"""
if not zipfile.is_zipfile(zip_path):
return None
try:
with zipfile.ZipFile(zip_path, "r") as zip_ref:
# List all files in the archive
infos_by_name = {info.filename: info for info in zip_ref.infolist()}
# Try direct path first
if internal_path in infos_by_name:
return _read_skill_archive_member(zip_ref, infos_by_name[internal_path])
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
for name, info in infos_by_name.items():
if name.endswith("/" + internal_path) or name == internal_path:
return _read_skill_archive_member(zip_ref, info)
# Not found
return None
except (zipfile.BadZipFile, KeyError):
return None
def _load_skill_archive_member(actual_skill_path: Path, skill_file_path: str, internal_path: str) -> tuple[bytes, str | None]:
"""Worker-thread body for the ``.skill`` branch of ``get_artifact``.
The ``exists`` / ``is_file`` probes, the ZIP open+extract, and the MIME
sniff (``mimetypes`` lazily stats the system MIME database on first use) are
blocking filesystem IO and must stay off the event loop. Raised
``HTTPException``s propagate through ``asyncio.to_thread`` unchanged,
preserving status codes.
"""
if not actual_skill_path.exists():
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
if not actual_skill_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {skill_file_path}")
content = _extract_file_from_skill_archive(actual_skill_path, internal_path)
if content is None:
raise HTTPException(status_code=404, detail=f"File '{internal_path}' not found in skill archive")
mime_type, _ = mimetypes.guess_type(internal_path)
return content, mime_type
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 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}")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
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)
if mime_type and mime_type.startswith("text/"):
return ("inline_file", mime_type)
if is_text_file_by_content(actual_path):
return ("inline_file", mime_type or "text/plain")
return ("inline_file", mime_type)
@router.get(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File",
description="Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, while active web content is always downloaded.",
)
@require_permission("threads", "read", owner_check=True)
async def get_artifact(thread_id: ThreadId, path: str, request: Request, download: bool = False) -> Response:
"""Get an artifact file by its path.
The endpoint automatically detects file types and returns appropriate content types.
Use the `download` query parameter to force file download for non-active content.
Args:
thread_id: The thread ID.
path: The artifact path with virtual prefix (e.g., mnt/user-data/outputs/file.txt).
request: FastAPI request object (automatically injected).
Returns:
The file content as a FileResponse with appropriate content type:
- Active content (HTML/XHTML/SVG): Served as download attachment
- Text files: Plain text with proper MIME type
- Binary files: Inline display with download option
Raises:
HTTPException:
- 400 if path is invalid or not a file
- 403 if access denied (path traversal detected)
- 404 if file not found
Query Parameters:
download (bool): If true, forces attachment download for file types that are
otherwise returned inline or as plain text. Active HTML/XHTML/SVG content
is always downloaded regardless of this flag.
Example:
- Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt`
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
- Active web content such as `.html`, `.xhtml`, and `.svg` artifacts is always downloaded
"""
# Trusted internal callers may act on behalf of a thread's owner via the
# owner-user-id header (honored only after the internal token validates).
# The header carries the raw platform owner id, while runs store files
# under the make_safe_user_id bucket (the same normalization the channel
# file pipeline and the memory router apply), so resolution uses the
# normalized id. Browser/API callers get None here and fall back to the
# effective user.
raw_owner_user_id = get_trusted_internal_owner_user_id(request)
owner_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else None
# Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md)
if ".skill/" in path:
# Split the path at ".skill/" to get the ZIP file path and internal path
skill_marker = ".skill/"
marker_pos = path.find(skill_marker)
skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill"
internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md"
actual_skill_path = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, skill_file_path, user_id=owner_user_id)
# Offload the stat probes + ZIP open/extract + MIME sniff (blocking filesystem IO).
content, mime_type = await asyncio.to_thread(_load_skill_archive_member, actual_skill_path, skill_file_path, internal_path)
# Add cache headers to avoid repeated ZIP extraction (cache for 5 minutes)
cache_headers = {"Cache-Control": "private, max-age=300"}
download_name = Path(internal_path).name or actual_skill_path.stem
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 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:
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=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 (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
# execution in the application origin when users open generated artifacts.
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 honors byte-Range requests for large text previews and
# media seeking without buffering the full artifact in the Gateway.
return FileResponse(
path=actual_path,
media_type=mime_type,
headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)},
)
raise AssertionError(f"Unhandled artifact response kind: {kind!r}")
@router.put(
"/threads/{thread_id}/artifacts/{path:path}",
response_model=ArtifactUpdateResponse,
summary="Update Artifact File",
description="Replace an existing UTF-8 text artifact after verifying that its content has not changed.",
)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def update_artifact(
thread_id: ThreadId,
path: str,
body: ArtifactUpdateRequest,
request: Request,
) -> ArtifactUpdateResponse:
"""Update an existing text artifact while the thread has no active run."""
virtual_path = _normalize_editable_artifact_path(path)
raw_owner_user_id = get_trusted_internal_owner_user_id(request)
effective_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else get_effective_user_id()
sandbox_provider = None
sandbox_id: str | None = None
sandbox = None
try:
async with reserve_artifact_write(request, thread_id, user_id=effective_user_id):
actual_path = await asyncio.to_thread(
resolve_thread_virtual_path,
thread_id,
virtual_path,
user_id=effective_user_id,
)
current, file_stat = await asyncio.to_thread(
_load_editable_artifact,
actual_path,
virtual_path,
body.expected_sha256,
)
updated = _encode_artifact_update(body.content)
sandbox_provider = get_sandbox_provider()
if not bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)):
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=effective_user_id)
sandbox = sandbox_provider.get(sandbox_id)
if sandbox is None:
raise RuntimeError("Failed to acquire sandbox for artifact update")
try:
if sandbox is not None:
await asyncio.to_thread(_sync_artifact_to_sandbox, sandbox, virtual_path, updated)
await asyncio.to_thread(_replace_artifact_atomically, actual_path, updated, file_stat)
except Exception:
if sandbox is not None:
try:
await asyncio.to_thread(_sync_artifact_to_sandbox, sandbox, virtual_path, current)
except Exception:
logger.exception("Failed to roll back remote artifact after artifact update failure: %s", virtual_path)
raise
except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Save after the run finishes.") from None
except HTTPException:
raise
except Exception:
logger.exception("Failed to update artifact %s for thread %s", path, thread_id)
raise HTTPException(status_code=500, detail="Failed to update artifact") from None
finally:
if sandbox_id is not None and sandbox_provider is not None:
try:
await asyncio.to_thread(sandbox_provider.release, sandbox_id)
except Exception:
logger.warning("Failed to release sandbox after artifact update: %s", sandbox_id, exc_info=True)
content_sha256 = hashlib.sha256(updated).hexdigest()
return ArtifactUpdateResponse(
path=virtual_path,
sha256=content_sha256,
size=len(updated),
)