diff --git a/README.md b/README.md index 223aac59ee..20b3c791b2 100644 --- a/README.md +++ b/README.md @@ -2467,6 +2467,18 @@ CSV and TSV artifacts open as tables in the artifact panel and in a separate win HTML artifact previews resolve relative assets from the artifact directory unless the document contains a real `` element. Base-tag examples in comments or script text do not change that resolution. +With authentication enabled, static resources in the same thread's artifact route +(images, responsive images, classic scripts and CSS, including nested imports) +are fetched by the parent and inlined without weakening the opaque iframe sandbox. +Preparation has resource, byte, CSS-depth and time limits and reports partial +previews. Dynamic JavaScript requests, ES module imports, nested iframes and +navigation remain subject to sandbox restrictions; see the +[frontend preview notes](frontend/README.md#development-notes) for the supported +scope and limits. +Optional resource failures, including 401 responses, keep the conversation open +with a partial-preview notice. HTML doctypes and doctype-less rendering modes +are preserved, and CSS references inside inline SVG styles are prepared too. + Markdown report previews in the artifact panel and separate window resolve local images from the report's own directory and conversation. Both relative paths such as `./charts/summary.png` and absolute `/mnt/...` paths are supported; diff --git a/frontend/README.md b/frontend/README.md index 6957577f40..f6bfa087eb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -143,6 +143,25 @@ src/ ## Development Notes +HTML artifact previews keep an opaque iframe sandbox. Static sibling images, +responsive image sources, classic scripts, stylesheets, CSS imports and CSS +`url()` resources in the same thread's artifact route are fetched by the +authenticated parent and inlined as data URLs. Existing external +resources and external `` URLs retain their browser behavior. Resource +preparation is capped at 32 resources, 4 MiB per resource, 16 MiB total, eight +nested CSS imports, 24 MiB of expanded references and ten seconds; unavailable or oversized resources show a +partial-preview notice. Switching artifacts cancels preparation and releases +the generated document Blob URL. +Resource 401 responses remain local to the partial preview; only the document +load uses the application's login-redirect policy. Preparation preserves the +original doctype (including its absence) and handles inline SVG styles. + +This supports static generated reports rather than a complete website runtime. +ES module imports, resources created dynamically by JavaScript, `fetch()`/XHR, +iframes and navigation still follow opaque-origin browser restrictions and do +not receive the parent session. View source or download the files when a report +requires those features. + - Uses pnpm workspaces (see `packageManager` in package.json) - Webpack is the default development bundler until the upstream Turbopack PostCSS worker leak is fixed in a stable Next.js release (#5132). Set `DEER_FLOW_DEV_BUNDLER=turbo` to opt in to Turbopack for local diagnosis, or `DEER_FLOW_DEV_BUNDLER=webpack` to select Webpack explicitly. Reconsider the default after the stable fix is verified on macOS arm64 and Linux. - Environment validation can be skipped with `SKIP_ENV_VALIDATION=1` (useful for Docker) diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 63d49d01a6..810a36aa36 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -10,18 +10,20 @@ failure/replay-gap paths. Draft and confirmation regressions live in Answer details use `workspace/message-details` descriptors; skill run scoping is documented in `docs/skill-usage-ui.md`. -Encode raw paths preserving literal percent escapes. Decode Markdown destinations -once; match image names to raw paths. Preserve sanitization; key Streamdown by -file/thread/mock context plus scroll key. -File-type detection uses the basename so extensionless `Dockerfile` and -`Makefile` artifacts remain recognizable under nested or dotted directories. -HTML preview base detection skips DOM construction without case-insensitive `base` -text; otherwise an inert template distinguishes real tags from comments, scripts -and nested templates without rewriting source. Tests: `preview-base.dom.test.ts` -(happy-dom); textarea/title RCDATA needs real-browser checks due to parser gaps. -Other preview helpers retain the node test environment. -Language-map membership checks only own properties; inherited names such as -`constructor` and `__proto__` must keep the unknown-file download fallback. +Encode raw paths preserving literal percent escapes. Decode Markdown once; +match image names to raw paths. +Sanitize URLs; key Streamdown by file/thread/mock context and scroll key. +Detect file types by basename, including Dockerfile/Makefile. +Language maps use own properties; `constructor`/`__proto__` keep fallback. +HTML bases skip DOM without base text; inert templates ignore comments/scripts +and templates. `preview-base.dom.test.ts` (happy-dom); RCDATA needs +browser checks; other helpers use node. +HTML assets: credential GET, no 401 navigation; current-thread only, no redirects, +bound bytes/count/CSS depth/time/expansion; keep doctype, rewrite SVG style. +Keep opaque sandbox/data URLs; external refs stay browser-loaded. +Abort stale work; revoke URLs; gate content/URL/scroll. +Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`, +`tests/e2e/artifact-preview.spec.ts`. 1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming. Voice transcript joining separates every adjacent pair of trimmed word segments, preserves CJK joins and provider whitespace, and attaches leading punctuation/percent signs while retaining spaces after sentence punctuation. Regression cases live in `tests/unit/core/voice-input/speech-recognition.test.ts`. 2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. diff --git a/frontend/src/components/workspace/artifacts/artifact-file-preview.tsx b/frontend/src/components/workspace/artifacts/artifact-file-preview.tsx index 125ea2ec22..4e3cf3bd23 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-preview.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-preview.tsx @@ -6,9 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultUrlTransform, type UrlTransform } from "streamdown"; import { Button } from "@/components/ui/button"; +import { prepareHtmlPreview } from "@/core/artifacts/html-assets"; import { getTabularDelimiter, - appendHtmlPreviewBaseHref, appendHtmlPreviewScrollRestoration, createHtmlPreviewScrollKey, HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, @@ -19,6 +19,7 @@ import { urlOfArtifact, } from "@/core/artifacts/utils"; import { extractCitationSources } from "@/core/citations/sources"; +import { useI18n } from "@/core/i18n/hooks"; import { SafeStreamdown, toStreamdownComponents, @@ -146,6 +147,7 @@ export function ArtifactFilePreview({ truncated?: boolean; active?: boolean; }) { + const { t } = useI18n(); const { filepath, threadId, isMock } = artifact ?? {}; const transformUrl = useCallback( (src, key, node) => @@ -164,7 +166,18 @@ export function ArtifactFilePreview({ () => createHtmlPreviewScrollKey(scrollKey), [scrollKey], ); - const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); + const previewIdentity = useMemo( + () => ({ content, language, scrollKey, url }), + [content, language, scrollKey, url], + ); + const [htmlPreview, setHtmlPreview] = useState<{ + identity: typeof previewIdentity; + url?: string; + failedResources?: number; + error?: boolean; + }>(); + const currentPreview = + htmlPreview?.identity === previewIdentity ? htmlPreview : undefined; const citationSources = useMemo( () => language === "markdown" ? extractCitationSources(content ?? "") : [], @@ -216,24 +229,39 @@ export function ArtifactFilePreview({ useEffect(() => { if (language !== "html") { - setHtmlPreviewUrl(undefined); return; } - - const previewContent = appendHtmlPreviewScrollRestoration( - appendHtmlPreviewBaseHref(content ?? "", url), - scrollKey, + const controller = new AbortController(); + let objectUrl: string | undefined; + void prepareHtmlPreview(content ?? "", url, controller.signal).then( + (prepared) => { + if (controller.signal.aborted) { + return; + } + const previewContent = appendHtmlPreviewScrollRestoration( + prepared.content, + scrollKey, + ); + objectUrl = URL.createObjectURL( + new Blob([previewContent], { type: "text/html;charset=utf-8" }), + ); + setHtmlPreview({ + identity: previewIdentity, + url: objectUrl, + failedResources: prepared.failedResources, + }); + }, + () => { + if (!controller.signal.aborted) + setHtmlPreview({ identity: previewIdentity, error: true }); + }, ); - const blob = new Blob([previewContent], { - type: "text/html;charset=utf-8", - }); - const objectUrl = URL.createObjectURL(blob); - setHtmlPreviewUrl(objectUrl); return () => { - URL.revokeObjectURL(objectUrl); + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [content, language, scrollKey, url]); + }, [content, language, scrollKey, url, previewIdentity]); const delimiter = getTabularDelimiter(language); if (delimiter !== null) { @@ -266,18 +294,29 @@ export function ArtifactFilePreview({ } if (language === "html") { return ( -