mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
feat(frontend): render markdown artifacts in the "open in new window" view (#5056)
* feat(frontend): render markdown artifacts in the new window The artifacts panel's "open in new window" action handed the browser the raw Gateway response. For markdown that is a `text/markdown` body the browser can only show as source, so the new window was a text dump rather than a reader. Route markdown artifacts to a new `/artifacts/view` page that renders them with the same components the panel uses (SafeStreamdown + the artifact rehype chain + citation links/panel), including the truncated-preview banner and its "load full file" action. Everything else keeps the raw Gateway URL — notably HTML/SVG, which the Gateway deliberately serves as a download so active content never executes in the application origin. - `core/artifacts/viewer.ts` centralizes which stored artifacts are markdown (`.skill` archives included, since they hold a SKILL.md), so the panel and the viewer route cannot drift. - `ArtifactFilePreview` and its siblings move out of `artifact-file-detail.tsx` into `artifact-file-preview.tsx`; otherwise the standalone route would pull the CodeMirror editor into its bundle. - The window title comes from the route's `generateMetadata`, not `document.title`, which the App Router overwrites after hydration. - The viewer reads content through `useStandaloneArtifactContent`, which shares `useArtifactContent`'s query key but not its `useThread` dependency, since a detached window has no thread context. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC * fix(frontend): keep the artifact target across re-authentication Review found the standalone viewer unrecoverable from an expired session. The window's target lives entirely in `?path=...&thread_id=...`, and both auth paths dropped it: - The layout guard redirected to `/login` with no `next` at all. A layout cannot read `searchParams`, so the guard moves into the page, which can — and rebuilds the full viewer address for `next`. The layout loses its AuthProvider along the way: nothing under this route reads `useAuth`, and the guard now makes a single `getServerSideUser` call per request. - The shared fetch wrapper built `next` from `window.location.pathname`, which silently truncated the query string. It now carries `search` too, so any route holding state in the query survives a 401, not just this one. `validateAuthNextPath` already accepts a query string. `buildArtifactViewerURL` is split out of `resolveArtifactOpenURL`: the guard needs the route itself, never the Gateway fallback that the latter takes for non-markdown targets. Tests: the login round trip (unit — the rebuilt URL survives `validateAuthNextPath` and parses back to the same target), the fetch wrapper preserving the query on 401 (unit), and the expired-session window reaching `/login` with the artifact intact (E2E). The E2E asserts on the popup's navigation *requests*, since `(auth)/layout` answers `/login` with a server redirect under DEER_FLOW_AUTH_DISABLED and no navigation commits. `tests/unit/core/models/api.test.ts` stubbed `window.location` without `search`; a real Location always has it. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC * fix(frontend): keep public showcase artifacts out of the auth gate Review found that the viewer's access check regressed `/showcase`. Those pages render with `isMock`, their artifacts are served by the unauthenticated demo route, and the raw artifact URL this window replaced stayed public — so gating the window unconditionally bounced every logged-out showcase visitor to /login for a document that is already public. `requiresAuthenticatedViewer` exempts a mock target only when `resolveStaticDemoArtifact` would actually serve it. The allowlist is the authority rather than the flag: `mock=true` is caller-supplied, so a target the demo route answers with 404 — a non-allowlisted path, or a thread that is not a demo thread — still needs a session. Covered in `tests/e2e-auth/`, since the default E2E config disables auth and cannot see this: a public showcase artifact renders without a session, while a non-allowlisted path and a missing mock flag both land on /login. Verified the positive case goes red without the exemption. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC
This commit is contained in:
parent
0cb356858b
commit
73e3699347
@ -50,7 +50,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
|
||||
### Source Layout (`src/`)
|
||||
|
||||
- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
|
||||
- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/artifacts/view` (chrome-free window that renders one markdown artifact with the panel's own renderer), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
|
||||
- **`components/`** — React components:
|
||||
- `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored)
|
||||
- `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored)
|
||||
|
||||
32
frontend/src/app/artifacts/view/layout.tsx
Normal file
32
frontend/src/app/artifacts/view/layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Chrome-free layout for the standalone artifact window.
|
||||
*
|
||||
* Deliberately not nested under `/workspace`: this route opens in its own
|
||||
* browser window, so it wants the same rich-content styles as the chat page
|
||||
* but none of its sidebar/thread shell.
|
||||
*
|
||||
* 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`.
|
||||
*/
|
||||
export default async function ArtifactViewerLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const locale = await detectLocaleServer();
|
||||
|
||||
return (
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
90
frontend/src/app/artifacts/view/page.tsx
Normal file
90
frontend/src/app/artifacts/view/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ArtifactViewer } from "@/components/workspace/artifacts/artifact-viewer";
|
||||
import {
|
||||
artifactViewerTitle,
|
||||
buildArtifactViewerURL,
|
||||
parseArtifactViewerQuery,
|
||||
requiresAuthenticatedViewer,
|
||||
type ArtifactViewerTarget,
|
||||
} from "@/core/artifacts/viewer";
|
||||
import { getServerSideUser } from "@/core/auth/server";
|
||||
import { assertNever, buildLoginUrl } from "@/core/auth/types";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
|
||||
const POST_LOGIN_FALLBACK = "/workspace";
|
||||
|
||||
type ArtifactViewerPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: ArtifactViewerPageProps): Promise<Metadata> {
|
||||
const target = parseArtifactViewerQuery(await searchParams);
|
||||
return { title: artifactViewerTitle(target?.filepath) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate the window, keeping the artifact reachable across a re-login.
|
||||
*
|
||||
* `ARTIFACT_VIEWER_ROUTE` alone identifies nothing — the target is entirely in
|
||||
* the query string — so an expired session has to carry the full address into
|
||||
* `next`, or the user lands on the default workspace with no way back to the
|
||||
* document they opened.
|
||||
*/
|
||||
async function requireViewerAccess(target: ArtifactViewerTarget | null) {
|
||||
if (target && !requiresAuthenticatedViewer(target)) {
|
||||
return;
|
||||
}
|
||||
const result = await getServerSideUser();
|
||||
switch (result.tag) {
|
||||
case "authenticated":
|
||||
return;
|
||||
case "unauthenticated":
|
||||
redirect(
|
||||
buildLoginUrl(
|
||||
target ? buildArtifactViewerURL(target) : POST_LOGIN_FALLBACK,
|
||||
),
|
||||
);
|
||||
case "needs_setup":
|
||||
case "system_setup_required":
|
||||
redirect("/setup");
|
||||
case "config_error":
|
||||
throw new Error(result.message);
|
||||
case "gateway_unavailable":
|
||||
// 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;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ArtifactViewerPage({
|
||||
searchParams,
|
||||
}: ArtifactViewerPageProps) {
|
||||
const target = parseArtifactViewerQuery(await searchParams);
|
||||
await requireViewerAccess(target);
|
||||
|
||||
if (!target) {
|
||||
const { t } = await getI18n();
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.artifactPreview.missingTarget}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ArtifactViewer
|
||||
filepath={target.filepath}
|
||||
threadId={target.threadId}
|
||||
isMock={target.isMock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -44,41 +44,35 @@ import {
|
||||
reconcileArtifactDraft,
|
||||
} from "@/core/artifacts/editing";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import {
|
||||
appendHtmlPreviewBaseHref,
|
||||
appendHtmlPreviewScrollRestoration,
|
||||
createHtmlPreviewScrollKey,
|
||||
getArtifactViewState,
|
||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
} from "@/core/artifacts/preview";
|
||||
import { getArtifactViewState } from "@/core/artifacts/preview";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
resolveArtifactOpenURL,
|
||||
resolveStoredArtifactLanguage,
|
||||
} from "@/core/artifacts/viewer";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { extractCitationSources } from "@/core/citations/sources";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { findToolCallResult } from "@/core/messages/utils";
|
||||
import { installSkill, SkillRequestError } from "@/core/skills/api";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
toStreamdownComponents,
|
||||
} from "@/core/streamdown/components";
|
||||
import {
|
||||
canBrowserPreviewFile,
|
||||
checkCodeFile,
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
} from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ArtifactLink } from "../citations/artifact-link";
|
||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import {
|
||||
ArtifactDownloadFallback,
|
||||
ArtifactFilePreview,
|
||||
ArtifactPreviewError,
|
||||
formatArtifactBytes,
|
||||
} from "./artifact-file-preview";
|
||||
import { useArtifacts } from "./context";
|
||||
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
|
||||
|
||||
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
|
||||
|
||||
@ -165,12 +159,13 @@ export function ArtifactFileDetail({
|
||||
language ??= "text";
|
||||
return { isCodeFile: true, language };
|
||||
}
|
||||
// Treat .skill files as markdown (they contain SKILL.md)
|
||||
if (isSkillFile) {
|
||||
return { isCodeFile: true, language: "markdown" };
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, isWriteFile, isSkillFile]);
|
||||
// Shared with the standalone viewer route so both agree on which stored
|
||||
// artifacts are markdown (notably .skill archives, which hold a SKILL.md).
|
||||
const language = resolveStoredArtifactLanguage(filepath);
|
||||
return language === null
|
||||
? { isCodeFile: false as const, language }
|
||||
: { isCodeFile: true as const, language };
|
||||
}, [filepath, isWriteFile]);
|
||||
const canPreviewInBrowser = useMemo(() => {
|
||||
return canBrowserPreviewFile(filepath);
|
||||
}, [filepath]);
|
||||
@ -543,7 +538,7 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={() => {
|
||||
const w = window.open(
|
||||
urlOfArtifact({ filepath, threadId, isMock }),
|
||||
resolveArtifactOpenURL({ filepath, threadId, isMock }),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
@ -707,242 +702,6 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactPreviewError({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
message,
|
||||
downloadLabel,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
message: string;
|
||||
downloadLabel: string;
|
||||
}) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatArtifactBytes(bytes: number | undefined) {
|
||||
if (bytes === undefined) return undefined;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function ArtifactDownloadFallback({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const filename = getFileName(filepath);
|
||||
const fileType = getFileExtensionDisplayName(filepath);
|
||||
|
||||
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>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium break-all">{filename}</div>
|
||||
<div className="text-muted-foreground text-sm">{fileType} file</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This file type cannot be previewed in the browser.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download: true,
|
||||
isMock,
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
scrollKey,
|
||||
url,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
scrollKey: string;
|
||||
url?: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const scrollPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollMessageKey = useMemo(
|
||||
() => createHtmlPreviewScrollKey(scrollKey),
|
||||
[scrollKey],
|
||||
);
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const citationSources = useMemo(
|
||||
() =>
|
||||
language === "markdown" ? extractCitationSources(content ?? "") : [],
|
||||
[content, language],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollPositionRef.current = { x: 0, y: 0 };
|
||||
}, [scrollMessageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (!isArtifactScrollMessage(event.data, scrollMessageKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === "save") {
|
||||
const x = scrollCoordinate(event.data.x);
|
||||
const y = scrollCoordinate(event.data.y);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
scrollPositionRef.current = { x, y };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
source: HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
key: scrollMessageKey,
|
||||
type: "restore",
|
||||
...scrollPositionRef.current,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [language, scrollMessageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
setHtmlPreviewUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContent = appendHtmlPreviewScrollRestoration(
|
||||
appendHtmlPreviewBaseHref(content ?? "", url),
|
||||
scrollKey,
|
||||
);
|
||||
const blob = new Blob([previewContent], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [content, language, scrollKey, url]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full overflow-auto px-4 py-3">
|
||||
<SafeStreamdown
|
||||
className="min-w-0"
|
||||
{...artifactMarkdownPlugins}
|
||||
components={toStreamdownComponents({ a: ArtifactLink })}
|
||||
>
|
||||
{content ?? ""}
|
||||
</SafeStreamdown>
|
||||
<CitationSourcesPanel sources={citationSources} className="mb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (language === "html") {
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="size-full"
|
||||
title="Artifact preview"
|
||||
// allow-scripts is needed for the scroll-restoration injected
|
||||
// script (appendHtmlPreviewScrollRestoration) which communicates
|
||||
// via postMessage. allow-same-origin is deliberately omitted: the
|
||||
// opaque origin prevents access to parent.document and cookies,
|
||||
// and postMessage(..., "*") works fine from it.
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={htmlPreviewUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isArtifactScrollMessage(
|
||||
data: unknown,
|
||||
key: string,
|
||||
): data is {
|
||||
type: "save" | "restore-request";
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
} {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"source" in data &&
|
||||
data.source === HTML_PREVIEW_SCROLL_MESSAGE_SOURCE &&
|
||||
"key" in data &&
|
||||
data.key === key &&
|
||||
"type" in data &&
|
||||
(data.type === "save" || data.type === "restore-request")
|
||||
);
|
||||
}
|
||||
|
||||
function scrollCoordinate(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function useThrottledValue(
|
||||
value: string,
|
||||
intervalMs: number,
|
||||
|
||||
@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
appendHtmlPreviewBaseHref,
|
||||
appendHtmlPreviewScrollRestoration,
|
||||
createHtmlPreviewScrollKey,
|
||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
} from "@/core/artifacts/preview";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { extractCitationSources } from "@/core/citations/sources";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
toStreamdownComponents,
|
||||
} from "@/core/streamdown/components";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
} from "@/core/utils/files";
|
||||
|
||||
import { ArtifactLink } from "../citations/artifact-link";
|
||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
|
||||
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
|
||||
|
||||
export function ArtifactPreviewError({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
message,
|
||||
downloadLabel,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
message: string;
|
||||
downloadLabel: string;
|
||||
}) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatArtifactBytes(bytes: number | undefined) {
|
||||
if (bytes === undefined) return undefined;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
export function ArtifactDownloadFallback({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const filename = getFileName(filepath);
|
||||
const fileType = getFileExtensionDisplayName(filepath);
|
||||
|
||||
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>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium break-all">{filename}</div>
|
||||
<div className="text-muted-foreground text-sm">{fileType} file</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This file type cannot be previewed in the browser.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download: true,
|
||||
isMock,
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
scrollKey,
|
||||
url,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
scrollKey: string;
|
||||
url?: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const scrollPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollMessageKey = useMemo(
|
||||
() => createHtmlPreviewScrollKey(scrollKey),
|
||||
[scrollKey],
|
||||
);
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const citationSources = useMemo(
|
||||
() =>
|
||||
language === "markdown" ? extractCitationSources(content ?? "") : [],
|
||||
[content, language],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollPositionRef.current = { x: 0, y: 0 };
|
||||
}, [scrollMessageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (!isArtifactScrollMessage(event.data, scrollMessageKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === "save") {
|
||||
const x = scrollCoordinate(event.data.x);
|
||||
const y = scrollCoordinate(event.data.y);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
scrollPositionRef.current = { x, y };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
source: HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
key: scrollMessageKey,
|
||||
type: "restore",
|
||||
...scrollPositionRef.current,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [language, scrollMessageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
setHtmlPreviewUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContent = appendHtmlPreviewScrollRestoration(
|
||||
appendHtmlPreviewBaseHref(content ?? "", url),
|
||||
scrollKey,
|
||||
);
|
||||
const blob = new Blob([previewContent], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [content, language, scrollKey, url]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full overflow-auto px-4 py-3">
|
||||
<SafeStreamdown
|
||||
className="min-w-0"
|
||||
{...artifactMarkdownPlugins}
|
||||
components={toStreamdownComponents({ a: ArtifactLink })}
|
||||
>
|
||||
{content ?? ""}
|
||||
</SafeStreamdown>
|
||||
<CitationSourcesPanel sources={citationSources} className="mb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (language === "html") {
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="size-full"
|
||||
title="Artifact preview"
|
||||
// allow-scripts is needed for the scroll-restoration injected
|
||||
// script (appendHtmlPreviewScrollRestoration) which communicates
|
||||
// via postMessage. allow-same-origin is deliberately omitted: the
|
||||
// opaque origin prevents access to parent.document and cookies,
|
||||
// and postMessage(..., "*") works fine from it.
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={htmlPreviewUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isArtifactScrollMessage(
|
||||
data: unknown,
|
||||
key: string,
|
||||
): data is {
|
||||
type: "save" | "restore-request";
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
} {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"source" in data &&
|
||||
data.source === HTML_PREVIEW_SCROLL_MESSAGE_SOURCE &&
|
||||
"key" in data &&
|
||||
data.key === key &&
|
||||
"type" in data &&
|
||||
(data.type === "save" || data.type === "restore-request")
|
||||
);
|
||||
}
|
||||
|
||||
function scrollCoordinate(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
130
frontend/src/components/workspace/artifacts/artifact-viewer.tsx
Normal file
130
frontend/src/components/workspace/artifacts/artifact-viewer.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStandaloneArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { getFileIcon, getFileName } from "@/core/utils/files";
|
||||
|
||||
import {
|
||||
formatArtifactBytes,
|
||||
ArtifactFilePreview,
|
||||
} from "./artifact-file-preview";
|
||||
|
||||
/**
|
||||
* Standalone markdown artifact window.
|
||||
*
|
||||
* The artifacts panel's "open in new window" action used to hand the browser
|
||||
* the raw Gateway response, which shows markdown as its own source. This
|
||||
* renders it with the same components the panel uses, so the new window is a
|
||||
* reader rather than a text dump. Markdown only — HTML and SVG artifacts stay
|
||||
* on the Gateway's download path so active content never runs in this origin.
|
||||
*/
|
||||
export function ArtifactViewer({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock = false,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const filename = getFileName(filepath);
|
||||
const {
|
||||
content,
|
||||
url,
|
||||
truncated,
|
||||
previewBytes,
|
||||
totalBytes,
|
||||
fullContentRequested,
|
||||
loadFullContent,
|
||||
isLoading,
|
||||
error,
|
||||
} = useStandaloneArtifactContent({ filepath, threadId, isMock });
|
||||
|
||||
const isLoadingFullContent = fullContentRequested && isLoading;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<header className="border-border bg-background/95 sticky top-0 z-10 flex shrink-0 items-center gap-3 border-b px-4 py-3 backdrop-blur">
|
||||
<div className="text-muted-foreground shrink-0">
|
||||
{getFileIcon(filepath, "size-4")}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium" title={filepath}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{filepath}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
{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>
|
||||
</header>
|
||||
|
||||
{truncated && (
|
||||
<div className="border-border bg-muted/40 flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t.artifactPreview.limited(
|
||||
formatArtifactBytes(previewBytes) ?? "1 MiB",
|
||||
formatArtifactBytes(totalBytes),
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={loadFullContent}>
|
||||
{t.artifactPreview.loadFullFile}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLoadingFullContent && (
|
||||
<div className="border-border text-muted-foreground flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
{t.artifactPreview.loadingFullFile}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="mx-auto min-h-0 w-full max-w-4xl flex-1 overflow-hidden">
|
||||
{error ? (
|
||||
<p className="text-muted-foreground p-6 text-sm">
|
||||
{t.artifactPreview.previewFailed}
|
||||
</p>
|
||||
) : content === undefined ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-6 text-sm">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactFilePreview
|
||||
content={content}
|
||||
language="markdown"
|
||||
scrollKey={filepath}
|
||||
url={url}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-preview";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
|
||||
@ -83,7 +83,12 @@ export async function fetch(
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = buildLoginUrl(window.location.pathname);
|
||||
// Include the search string: routes that carry their target in the query
|
||||
// (e.g. the standalone artifact viewer) are otherwise unrecoverable after
|
||||
// login, which lands on the default workspace instead.
|
||||
window.location.href = buildLoginUrl(
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
|
||||
@ -75,3 +75,58 @@ export function useArtifactContent({
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact content for the standalone viewer route.
|
||||
*
|
||||
* Deliberately separate from ``useArtifactContent``: that hook reads the live
|
||||
* thread stream through ``useThread`` so it can refetch when a run settles,
|
||||
* and the viewer window has no thread context to read. The query key matches
|
||||
* so both share the cache when they happen to run in the same document.
|
||||
*/
|
||||
export function useStandaloneArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock = false,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const [fullContentSelection, setFullContentSelection] = useState<{
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
} | null>(null);
|
||||
const fullContentRequested =
|
||||
fullContentSelection?.filepath === filepath &&
|
||||
fullContentSelection.threadId === threadId;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
|
||||
queryFn: () =>
|
||||
loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
full: fullContentRequested,
|
||||
}),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const loadFullContent = useCallback(() => {
|
||||
setFullContentSelection({ filepath, threadId });
|
||||
}, [filepath, threadId]);
|
||||
|
||||
return {
|
||||
content: data?.content,
|
||||
url: data?.url,
|
||||
truncated: data?.truncated ?? false,
|
||||
previewBytes: data?.previewBytes,
|
||||
totalBytes: data?.totalBytes,
|
||||
fullContentRequested,
|
||||
loadFullContent,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
141
frontend/src/core/artifacts/viewer.ts
Normal file
141
frontend/src/core/artifacts/viewer.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { resolveStaticDemoArtifact } from "@/core/threads/static-demo";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
/** Standalone route that renders a stored artifact with the app's own renderer. */
|
||||
export const ARTIFACT_VIEWER_ROUTE = "/artifacts/view";
|
||||
|
||||
export type ArtifactViewerTarget = {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Language the artifacts panel renders a *stored* artifact with.
|
||||
*
|
||||
* `.skill` archives are ZIPs whose `SKILL.md` member is what the panel loads
|
||||
* (see `loadArtifactContent`), so they render as markdown like the panel does.
|
||||
* Kept here rather than inlined in the panel so the standalone viewer and the
|
||||
* panel cannot drift on what "this is markdown" means.
|
||||
*/
|
||||
export function resolveStoredArtifactLanguage(filepath: string) {
|
||||
if (filepath.endsWith(".skill")) {
|
||||
return "markdown";
|
||||
}
|
||||
return checkCodeFile(filepath).language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target for the artifacts panel's "open in new window" action.
|
||||
*
|
||||
* Markdown goes to the in-app viewer route, which renders it 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, which the Gateway deliberately serves as a
|
||||
* download so active content never executes in the application origin.
|
||||
*/
|
||||
export function resolveArtifactOpenURL({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock = false,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
if (resolveStoredArtifactLanguage(filepath) !== "markdown") {
|
||||
return urlOfArtifact({ filepath, threadId, isMock });
|
||||
}
|
||||
return buildArtifactViewerURL({ filepath, threadId, isMock });
|
||||
}
|
||||
|
||||
/**
|
||||
* Address of the viewer window for *target*.
|
||||
*
|
||||
* Unlike `resolveArtifactOpenURL` this never falls back to the Gateway URL:
|
||||
* callers that already are the viewer window — the auth guard rebuilding its
|
||||
* own address for a post-login return — need the route itself.
|
||||
*/
|
||||
export function buildArtifactViewerURL({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
}: ArtifactViewerTarget) {
|
||||
const params = new URLSearchParams({ path: filepath, thread_id: threadId });
|
||||
if (isMock) {
|
||||
params.set("mock", "true");
|
||||
}
|
||||
return `${ARTIFACT_VIEWER_ROUTE}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Read a viewer target back out of the route's query string. */
|
||||
export function parseArtifactViewerParams(
|
||||
params: URLSearchParams,
|
||||
): ArtifactViewerTarget | null {
|
||||
const filepath = params.get("path")?.trim();
|
||||
const threadId = params.get("thread_id")?.trim();
|
||||
if (!filepath || !threadId) {
|
||||
return null;
|
||||
}
|
||||
return { filepath, threadId, isMock: params.get("mock") === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Next.js `searchParams` record into a viewer target.
|
||||
*
|
||||
* A repeated query parameter arrives as an array; take the first value rather
|
||||
* than letting a second `?path=` appended to a shared link decide what the
|
||||
* window loads.
|
||||
*/
|
||||
export function parseArtifactViewerQuery(
|
||||
query: Record<string, string | string[] | undefined> | undefined,
|
||||
): ArtifactViewerTarget | null {
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
const first = Array.isArray(value) ? value[0] : value;
|
||||
if (first !== undefined) {
|
||||
params.set(key, first);
|
||||
}
|
||||
}
|
||||
return parseArtifactViewerParams(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-tab title for the viewer window.
|
||||
*
|
||||
* Applied through the route's `generateMetadata`, not `document.title`: the
|
||||
* App Router owns the title element and re-applies the layout's metadata over
|
||||
* anything an effect writes.
|
||||
*/
|
||||
export function artifactViewerTitle(filepath: string | undefined) {
|
||||
return filepath ? `${getFileName(filepath)} - DeerFlow` : "DeerFlow";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer window has to sit behind the user-auth gate.
|
||||
*
|
||||
* Public `/showcase` threads render with `isMock`, and their artifacts are
|
||||
* served by the unauthenticated demo route, which answers only for an
|
||||
* allowlisted set of files. Gating those would bounce every logged-out
|
||||
* showcase visitor to /login for a document that is already public — and the
|
||||
* raw artifact URL this window replaced stayed reachable.
|
||||
*
|
||||
* The allowlist is the authority, not the flag: `mock=true` is caller-supplied
|
||||
* and on its own grants nothing, because a target the demo route would answer
|
||||
* with 404 still needs a session.
|
||||
*/
|
||||
export function requiresAuthenticatedViewer(target: ArtifactViewerTarget) {
|
||||
if (!target.isMock) {
|
||||
return true;
|
||||
}
|
||||
const segments = target.filepath
|
||||
.replace(/^\/+/, "")
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment));
|
||||
return resolveStaticDemoArtifact(target.threadId, segments) === null;
|
||||
}
|
||||
@ -124,6 +124,8 @@ export const enUS: Translations = {
|
||||
loadingFullFile: "Loading full file...",
|
||||
previewFailed:
|
||||
"This file could not be previewed. You can still download it.",
|
||||
viewSource: "View source",
|
||||
missingTarget: "This link does not say which artifact to display.",
|
||||
},
|
||||
|
||||
// Citations
|
||||
|
||||
@ -102,6 +102,8 @@ export interface Translations {
|
||||
loadFullFile: string;
|
||||
loadingFullFile: string;
|
||||
previewFailed: string;
|
||||
viewSource: string;
|
||||
missingTarget: string;
|
||||
};
|
||||
|
||||
// Citations
|
||||
|
||||
@ -121,6 +121,8 @@ export const zhCN: Translations = {
|
||||
loadFullFile: "加载完整文件",
|
||||
loadingFullFile: "正在加载完整文件...",
|
||||
previewFailed: "无法预览此文件,但仍可下载原始文件。",
|
||||
viewSource: "查看原始文件",
|
||||
missingTarget: "该链接没有指明要展示哪个文件。",
|
||||
},
|
||||
|
||||
// Citations
|
||||
|
||||
59
frontend/tests/e2e-auth/artifact-viewer-showcase.spec.ts
Normal file
59
frontend/tests/e2e-auth/artifact-viewer-showcase.spec.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The default E2E config runs with auth disabled, so it cannot see this: the
|
||||
* standalone artifact window must stay reachable for a logged-out visitor when
|
||||
* the target is a public showcase artifact, and must not for anything else.
|
||||
*/
|
||||
|
||||
// An allowlisted showcase artifact — see STATIC_DEMO_ARTIFACTS.
|
||||
const DEMO_THREAD_ID = "3823e443-4e2b-4679-b496-a9506eae462b";
|
||||
const DEMO_ARTIFACT = "/mnt/user-data/outputs/fei-fei-li-podcast-timeline.md";
|
||||
|
||||
function viewerUrl(params: Record<string, string>) {
|
||||
return `/artifacts/view?${new URLSearchParams(params).toString()}`;
|
||||
}
|
||||
|
||||
test.describe("standalone artifact viewer access", () => {
|
||||
test("renders a public showcase artifact without a session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
viewerUrl({
|
||||
path: DEMO_ARTIFACT,
|
||||
thread_id: DEMO_THREAD_ID,
|
||||
mock: "true",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL(/\/artifacts\/view/);
|
||||
await expect(
|
||||
page.getByText("fei-fei-li-podcast-timeline.md").first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("h1").first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("sends a logged-out visitor to login for a non-public artifact", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
viewerUrl({
|
||||
path: "/mnt/user-data/outputs/private-notes.md",
|
||||
thread_id: DEMO_THREAD_ID,
|
||||
mock: "true",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL(/\/login\?next=/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("sends a logged-out visitor to login when the mock flag is absent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
viewerUrl({ path: DEMO_ARTIFACT, thread_id: DEMO_THREAD_ID }),
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL(/\/login\?next=/, { timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
257
frontend/tests/e2e/artifact-viewer-window.spec.ts
Normal file
257
frontend/tests/e2e/artifact-viewer-window.spec.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
/**
|
||||
* Thread endpoints the workspace shell polls that `mockLangGraphAPI` does not
|
||||
* cover. Left unmocked they reach a real Gateway when one happens to be
|
||||
* running locally, and its 401 bounces the page to /login before the artifact
|
||||
* panel ever opens. Answer with the same 5xx a missing backend produces, which
|
||||
* these panels already tolerate.
|
||||
*/
|
||||
async function stubUnmockedThreadEndpoints(page: Page, threadId: string) {
|
||||
for (const suffix of ["token-usage", "mcp-tasks**"]) {
|
||||
await page.route(`**/api/threads/${threadId}/${suffix}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: "{}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MARKDOWN_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.md";
|
||||
const HTML_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.html";
|
||||
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003130";
|
||||
const HTML_THREAD_ID = "00000000-0000-0000-0000-000000003131";
|
||||
const EXPIRED_THREAD_ID = "00000000-0000-0000-0000-000000003132";
|
||||
const ARTIFACT_VIEWER_PATH = "/artifacts/view";
|
||||
|
||||
function presentFilesMessages(path: string) {
|
||||
return [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-present-file",
|
||||
content: [{ type: "text", text: "Create a report" }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-present-file",
|
||||
content: "The report has been written. Now let me present the file.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "present-file-artifact",
|
||||
name: "present_files",
|
||||
args: { filepaths: [path] },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test.describe("Artifact viewer window", () => {
|
||||
test("renders a markdown artifact instead of its source in the new window", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MARKDOWN_THREAD_ID,
|
||||
title: "Markdown artifact viewer window",
|
||||
messages: presentFilesMessages(MARKDOWN_ARTIFACT_PATH),
|
||||
artifacts: [MARKDOWN_ARTIFACT_PATH],
|
||||
},
|
||||
],
|
||||
});
|
||||
await stubUnmockedThreadEndpoints(page, MARKDOWN_THREAD_ID);
|
||||
// Registered on the context, not the page: the detached viewer window
|
||||
// fetches the same artifact and page routes do not reach it.
|
||||
await page
|
||||
.context()
|
||||
.route(
|
||||
`**/api/threads/${MARKDOWN_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/markdown",
|
||||
body: "# Quarterly Report\n\n测试内容 1\n",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/workspace/chats/${MARKDOWN_THREAD_ID}`);
|
||||
await expect(page.getByText("presented-report.md")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText("presented-report.md").first().click();
|
||||
|
||||
const artifactsPanel = page.locator("#artifacts");
|
||||
await expect(artifactsPanel.getByText("Quarterly Report")).toBeVisible();
|
||||
|
||||
const viewerPromise = page.context().waitForEvent("page");
|
||||
await artifactsPanel
|
||||
.getByRole("button", { name: "Open in new window" })
|
||||
.click();
|
||||
const viewer = await viewerPromise;
|
||||
await viewer.waitForLoadState("domcontentloaded");
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(viewer.url()).pathname)
|
||||
.toBe("/artifacts/view");
|
||||
const params = new URL(viewer.url()).searchParams;
|
||||
expect(params.get("path")).toBe(MARKDOWN_ARTIFACT_PATH);
|
||||
expect(params.get("thread_id")).toBe(MARKDOWN_THREAD_ID);
|
||||
|
||||
await expect(
|
||||
viewer.getByRole("heading", { name: "Quarterly Report" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(viewer.getByText("测试内容 1")).toBeVisible();
|
||||
// The raw markdown source must not be what the window shows.
|
||||
await expect(viewer.getByText("# Quarterly Report")).toHaveCount(0);
|
||||
await expect(viewer).toHaveTitle(/presented-report\.md/);
|
||||
|
||||
await viewer.close();
|
||||
});
|
||||
|
||||
test("keeps html artifacts on the gateway URL so they stay downloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: HTML_THREAD_ID,
|
||||
title: "Html artifact viewer window",
|
||||
messages: presentFilesMessages(HTML_ARTIFACT_PATH),
|
||||
artifacts: [HTML_ARTIFACT_PATH],
|
||||
},
|
||||
],
|
||||
});
|
||||
await stubUnmockedThreadEndpoints(page, HTML_THREAD_ID);
|
||||
await page
|
||||
.context()
|
||||
.route(
|
||||
`**/api/threads/${HTML_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.html`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><html><body><h1>Report draft</h1></body></html>",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/workspace/chats/${HTML_THREAD_ID}`);
|
||||
await expect(page.getByText("presented-report.html")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText("presented-report.html").first().click();
|
||||
|
||||
const artifactsPanel = page.locator("#artifacts");
|
||||
await expect(
|
||||
artifactsPanel.locator('iframe[title="Artifact preview"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const openedPromise = page.context().waitForEvent("page");
|
||||
await artifactsPanel
|
||||
.getByRole("button", { name: "Open in new window" })
|
||||
.click();
|
||||
const opened = await openedPromise;
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(opened.url()).pathname)
|
||||
.toBe(
|
||||
`/api/threads/${HTML_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.html`,
|
||||
);
|
||||
|
||||
await opened.close();
|
||||
});
|
||||
|
||||
test("returns to the same artifact after an expired session", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: EXPIRED_THREAD_ID,
|
||||
title: "Expired session viewer window",
|
||||
messages: presentFilesMessages(MARKDOWN_ARTIFACT_PATH),
|
||||
artifacts: [MARKDOWN_ARTIFACT_PATH],
|
||||
},
|
||||
],
|
||||
});
|
||||
await stubUnmockedThreadEndpoints(page, EXPIRED_THREAD_ID);
|
||||
// The session is valid for the panel and lapsed for the detached window,
|
||||
// keyed off the requesting frame so the panel's own refetches cannot race
|
||||
// for the 401.
|
||||
await page
|
||||
.context()
|
||||
.route(
|
||||
`**/api/threads/${EXPIRED_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`,
|
||||
(route) =>
|
||||
route.request().frame().url().includes(ARTIFACT_VIEWER_PATH)
|
||||
? route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Not authenticated" }),
|
||||
})
|
||||
: route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
body: "# Quarterly Report\n",
|
||||
}),
|
||||
);
|
||||
|
||||
// Record the popup's navigation *requests*, not its committed URLs. This
|
||||
// harness runs with DEER_FLOW_AUTH_DISABLED, so `(auth)/layout` treats the
|
||||
// window as signed in and answers /login with a server redirect that never
|
||||
// commits — the request is the only place the redirect target is visible.
|
||||
const navigated: string[] = [];
|
||||
page.context().on("page", (opened) => {
|
||||
opened.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
navigated.push(request.url());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${EXPIRED_THREAD_ID}`);
|
||||
await expect(page.getByText("presented-report.md")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText("presented-report.md").first().click();
|
||||
|
||||
const artifactsPanel = page.locator("#artifacts");
|
||||
await expect(artifactsPanel.getByText("Quarterly Report")).toBeVisible();
|
||||
|
||||
const viewerPromise = page.context().waitForEvent("page");
|
||||
await artifactsPanel
|
||||
.getByRole("button", { name: "Open in new window" })
|
||||
.click();
|
||||
const viewer = await viewerPromise;
|
||||
|
||||
// `about:blank` can appear here and `new URL` throws on an empty string,
|
||||
// so parse defensively — otherwise the poll predicate errors out instead
|
||||
// of retrying.
|
||||
const findLogin = () =>
|
||||
navigated.find((url) => {
|
||||
try {
|
||||
return new URL(url).pathname === "/login";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
await expect.poll(findLogin, { timeout: 15_000 }).toBeDefined();
|
||||
|
||||
const loginUrl = new URL(findLogin()!);
|
||||
const next = loginUrl.searchParams.get("next");
|
||||
expect(next).not.toBe(null);
|
||||
// The whole viewer address travels through login — query string included —
|
||||
// so the user comes back to this artifact, not the default workspace.
|
||||
const returned = new URL(next!, loginUrl.origin);
|
||||
expect(returned.pathname).toBe(ARTIFACT_VIEWER_PATH);
|
||||
expect(returned.searchParams.get("path")).toBe(MARKDOWN_ARTIFACT_PATH);
|
||||
expect(returned.searchParams.get("thread_id")).toBe(EXPIRED_THREAD_ID);
|
||||
|
||||
await viewer.close();
|
||||
});
|
||||
});
|
||||
@ -30,12 +30,16 @@ describe("layout performance boundaries", () => {
|
||||
expect(source("src/app/blog/layout.tsx")).toContain(
|
||||
'import "katex/dist/katex.min.css"',
|
||||
);
|
||||
const artifactViewerLayout = source("src/app/artifacts/view/layout.tsx");
|
||||
expect(artifactViewerLayout).toContain('import "streamdown/styles.css"');
|
||||
expect(artifactViewerLayout).toContain('import "katex/dist/katex.min.css"');
|
||||
});
|
||||
|
||||
it("passes only serializable locale state through server layouts", () => {
|
||||
for (const layout of [
|
||||
source("src/app/(auth)/layout.tsx"),
|
||||
source("src/app/workspace/layout.tsx"),
|
||||
source("src/app/artifacts/view/layout.tsx"),
|
||||
]) {
|
||||
expect(layout).toContain("detectLocaleServer");
|
||||
expect(layout).not.toContain("initialTranslations");
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
rs.mock("@/core/artifacts/loader", () => ({
|
||||
loadArtifactContent: rs.fn(),
|
||||
loadArtifactContentFromToolCall: rs.fn(),
|
||||
}));
|
||||
|
||||
import { ArtifactViewer } from "@/components/workspace/artifacts/artifact-viewer";
|
||||
import { loadArtifactContent } from "@/core/artifacts/loader";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
|
||||
const mockedLoadArtifactContent = rs.mocked(loadArtifactContent);
|
||||
const filepath = "/mnt/user-data/outputs/report.md";
|
||||
const threadId = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en-US">{children}</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderViewer(props?: { isMock?: boolean }) {
|
||||
return render(
|
||||
<Wrapper>
|
||||
<ArtifactViewer filepath={filepath} threadId={threadId} {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ArtifactViewer", () => {
|
||||
beforeEach(() => {
|
||||
mockedLoadArtifactContent.mockResolvedValue({
|
||||
content: "# Quarterly report\n\nRevenue is up.",
|
||||
url: urlOfArtifact({ filepath, threadId }),
|
||||
sha256: undefined,
|
||||
truncated: false,
|
||||
previewBytes: 36,
|
||||
totalBytes: 36,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockedLoadArtifactContent.mockReset();
|
||||
});
|
||||
|
||||
it("renders the markdown artifact with the app's markdown renderer", async () => {
|
||||
const { container } = renderViewer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("h1")?.textContent).toContain(
|
||||
"Quarterly report",
|
||||
);
|
||||
});
|
||||
expect(container.textContent).not.toContain("# Quarterly report");
|
||||
expect(mockedLoadArtifactContent).toHaveBeenCalledWith({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock: false,
|
||||
full: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the mock artifact source when opened from a mock thread", async () => {
|
||||
renderViewer({ isMock: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadArtifactContent).toHaveBeenCalledWith({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock: true,
|
||||
full: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the whole file when a truncated preview is expanded", async () => {
|
||||
mockedLoadArtifactContent.mockImplementation(async ({ full }) => ({
|
||||
content: full ? "# Full report\n\nEverything." : "# Full rep",
|
||||
url: urlOfArtifact({ filepath, threadId }),
|
||||
sha256: undefined,
|
||||
truncated: !full,
|
||||
previewBytes: full ? 27 : 10,
|
||||
totalBytes: 27,
|
||||
}));
|
||||
const { container } = renderViewer();
|
||||
|
||||
const loadFull = await screen.findByRole("button", {
|
||||
name: "Load full file",
|
||||
});
|
||||
loadFull.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain("Everything.");
|
||||
});
|
||||
expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock: false,
|
||||
full: true,
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Load full file" })).toBe(null);
|
||||
});
|
||||
|
||||
it("offers a download when the artifact cannot be loaded", async () => {
|
||||
mockedLoadArtifactContent.mockRejectedValue(new Error("boom"));
|
||||
|
||||
renderViewer();
|
||||
|
||||
const download = await screen.findByRole("link", { name: /download/i });
|
||||
expect(download.getAttribute("href")).toBe(
|
||||
urlOfArtifact({ filepath, threadId, download: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
41
frontend/tests/unit/core/api/fetcher.dom.test.ts
Normal file
41
frontend/tests/unit/core/api/fetcher.dom.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
|
||||
import { fetch as apiFetch } from "@/core/api/fetcher";
|
||||
|
||||
describe("api fetcher unauthorized redirect", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = rs.fn(
|
||||
async () => new Response("", { status: 401 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns the caller to the full URL, query string included", async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
|
||||
);
|
||||
|
||||
// The wrapper redirects and then throws UnauthorizedError; the redirect
|
||||
// target is what this test is about.
|
||||
await expect(
|
||||
apiFetch("/api/threads/t-1/artifacts/mnt/user-data/outputs/report.md"),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(window.location.href).toContain("/login?next=");
|
||||
const next = new URL(
|
||||
window.location.href,
|
||||
"http://localhost",
|
||||
).searchParams.get("next");
|
||||
expect(next).toBe(
|
||||
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
252
frontend/tests/unit/core/artifacts/viewer.test.ts
Normal file
252
frontend/tests/unit/core/artifacts/viewer.test.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { describe, expect, test } from "@rstest/core";
|
||||
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
ARTIFACT_VIEWER_ROUTE,
|
||||
artifactViewerTitle,
|
||||
buildArtifactViewerURL,
|
||||
requiresAuthenticatedViewer,
|
||||
parseArtifactViewerParams,
|
||||
parseArtifactViewerQuery,
|
||||
resolveArtifactOpenURL,
|
||||
} from "@/core/artifacts/viewer";
|
||||
import { validateAuthNextPath } from "@/core/auth/next-path";
|
||||
import { buildLoginUrl } from "@/core/auth/types";
|
||||
|
||||
const threadId = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||||
|
||||
function viewerParams(url: string) {
|
||||
expect(url.startsWith(`${ARTIFACT_VIEWER_ROUTE}?`)).toBe(true);
|
||||
return new URLSearchParams(url.slice(url.indexOf("?") + 1));
|
||||
}
|
||||
|
||||
describe("resolveArtifactOpenURL", () => {
|
||||
test("routes markdown artifacts to the standalone viewer", () => {
|
||||
const filepath = "/mnt/user-data/outputs/report.md";
|
||||
|
||||
const params = viewerParams(resolveArtifactOpenURL({ filepath, threadId }));
|
||||
|
||||
expect(params.get("path")).toBe(filepath);
|
||||
expect(params.get("thread_id")).toBe(threadId);
|
||||
expect(params.get("mock")).toBe(null);
|
||||
});
|
||||
|
||||
test("routes skill archives to the viewer because they render as markdown", () => {
|
||||
const filepath = "/mnt/user-data/outputs/my-helper.skill";
|
||||
|
||||
const params = viewerParams(resolveArtifactOpenURL({ filepath, threadId }));
|
||||
|
||||
expect(params.get("path")).toBe(filepath);
|
||||
});
|
||||
|
||||
test("keeps html artifacts on the raw gateway URL so they stay downloads", () => {
|
||||
const filepath = "/mnt/user-data/outputs/page.html";
|
||||
|
||||
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
|
||||
urlOfArtifact({ filepath, threadId }),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps non-markdown text artifacts on the raw gateway URL", () => {
|
||||
const filepath = "/mnt/user-data/outputs/notes.mdx";
|
||||
|
||||
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
|
||||
urlOfArtifact({ filepath, threadId }),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps binary artifacts on the raw gateway URL", () => {
|
||||
const filepath = "/mnt/user-data/outputs/diagram.png";
|
||||
|
||||
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
|
||||
urlOfArtifact({ filepath, threadId }),
|
||||
);
|
||||
});
|
||||
|
||||
test("carries the mock flag into the viewer URL", () => {
|
||||
const params = viewerParams(
|
||||
resolveArtifactOpenURL({
|
||||
filepath: "/mnt/user-data/outputs/report.md",
|
||||
threadId,
|
||||
isMock: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(params.get("mock")).toBe("true");
|
||||
});
|
||||
|
||||
test("carries the mock flag into the raw URL for non-markdown artifacts", () => {
|
||||
const filepath = "/mnt/user-data/outputs/diagram.png";
|
||||
|
||||
expect(resolveArtifactOpenURL({ filepath, threadId, isMock: true })).toBe(
|
||||
urlOfArtifact({ filepath, threadId, isMock: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArtifactViewerParams", () => {
|
||||
test("round-trips a URL built by resolveArtifactOpenURL", () => {
|
||||
const filepath = "/mnt/user-data/outputs/sub dir/rapport été.md";
|
||||
const url = resolveArtifactOpenURL({ filepath, threadId, isMock: true });
|
||||
|
||||
expect(parseArtifactViewerParams(viewerParams(url))).toEqual({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults the mock flag to false when absent", () => {
|
||||
const params = new URLSearchParams({
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
thread_id: threadId,
|
||||
});
|
||||
|
||||
expect(parseArtifactViewerParams(params)?.isMock).toBe(false);
|
||||
});
|
||||
|
||||
test("returns null when the path is missing", () => {
|
||||
const params = new URLSearchParams({ thread_id: threadId });
|
||||
|
||||
expect(parseArtifactViewerParams(params)).toBe(null);
|
||||
});
|
||||
|
||||
test("returns null when the thread id is missing", () => {
|
||||
const params = new URLSearchParams({
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
});
|
||||
|
||||
expect(parseArtifactViewerParams(params)).toBe(null);
|
||||
});
|
||||
|
||||
test("returns null when the path is blank", () => {
|
||||
const params = new URLSearchParams({ path: " ", thread_id: threadId });
|
||||
|
||||
expect(parseArtifactViewerParams(params)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArtifactViewerQuery", () => {
|
||||
test("reads the Next.js searchParams record shape", () => {
|
||||
expect(
|
||||
parseArtifactViewerQuery({
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
thread_id: threadId,
|
||||
mock: "true",
|
||||
}),
|
||||
).toEqual({
|
||||
filepath: "/mnt/user-data/outputs/report.md",
|
||||
threadId,
|
||||
isMock: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value when a parameter is repeated", () => {
|
||||
expect(
|
||||
parseArtifactViewerQuery({
|
||||
path: ["/mnt/user-data/outputs/first.md", "/etc/passwd"],
|
||||
thread_id: threadId,
|
||||
})?.filepath,
|
||||
).toBe("/mnt/user-data/outputs/first.md");
|
||||
});
|
||||
|
||||
test("returns null when the record carries no target", () => {
|
||||
expect(parseArtifactViewerQuery({})).toBe(null);
|
||||
expect(parseArtifactViewerQuery(undefined)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifactViewerTitle", () => {
|
||||
test("names the window after the artifact file", () => {
|
||||
expect(artifactViewerTitle("/mnt/user-data/outputs/report.md")).toBe(
|
||||
"report.md - DeerFlow",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to the product name without a target", () => {
|
||||
expect(artifactViewerTitle(undefined)).toBe("DeerFlow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArtifactViewerURL", () => {
|
||||
test("addresses the viewer route for any target, markdown or not", () => {
|
||||
// resolveArtifactOpenURL sends non-markdown to the Gateway; rebuilding the
|
||||
// window's own address must not follow that branch.
|
||||
const params = viewerParams(
|
||||
buildArtifactViewerURL({
|
||||
filepath: "/mnt/user-data/outputs/diagram.png",
|
||||
threadId,
|
||||
isMock: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(params.get("path")).toBe("/mnt/user-data/outputs/diagram.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("returning to the viewer after re-authentication", () => {
|
||||
test("survives the login redirect and resolves back to the same artifact", () => {
|
||||
const target = {
|
||||
filepath: "/mnt/user-data/outputs/rapport été.md",
|
||||
threadId,
|
||||
isMock: true,
|
||||
};
|
||||
|
||||
const loginUrl = buildLoginUrl(buildArtifactViewerURL(target));
|
||||
const nextPath = new URLSearchParams(
|
||||
loginUrl.slice(loginUrl.indexOf("?") + 1),
|
||||
).get("next");
|
||||
|
||||
// The login page drops a `next` it considers unsafe — notably anything
|
||||
// containing a raw colon — which would strand the window on /workspace.
|
||||
expect(validateAuthNextPath(nextPath)).toBe(nextPath);
|
||||
expect(parseArtifactViewerParams(viewerParams(nextPath!))).toEqual(target);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresAuthenticatedViewer", () => {
|
||||
// A real allowlisted showcase artifact — see STATIC_DEMO_ARTIFACTS.
|
||||
const demoThreadId = "3823e443-4e2b-4679-b496-a9506eae462b";
|
||||
const demoFilepath = "/mnt/user-data/outputs/fei-fei-li-podcast-timeline.md";
|
||||
|
||||
test("lets a logged-out visitor read a public showcase artifact", () => {
|
||||
expect(
|
||||
requiresAuthenticatedViewer({
|
||||
filepath: demoFilepath,
|
||||
threadId: demoThreadId,
|
||||
isMock: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("gates a mock target the public demo route does not serve", () => {
|
||||
// `mock=true` is caller-supplied, so the allowlist has to be the authority.
|
||||
expect(
|
||||
requiresAuthenticatedViewer({
|
||||
filepath: "/mnt/user-data/outputs/private-notes.md",
|
||||
threadId: demoThreadId,
|
||||
isMock: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("gates a mock target on a thread that is not a demo thread", () => {
|
||||
expect(
|
||||
requiresAuthenticatedViewer({
|
||||
filepath: demoFilepath,
|
||||
threadId: "7cfa5f8f-0000-0000-0000-000000000000",
|
||||
isMock: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("gates the same artifact when the mock flag is absent", () => {
|
||||
expect(
|
||||
requiresAuthenticatedViewer({
|
||||
filepath: demoFilepath,
|
||||
threadId: demoThreadId,
|
||||
isMock: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -56,7 +56,9 @@ test("loadModels rejects unsuccessful gateway responses", async () => {
|
||||
});
|
||||
|
||||
test("loadModels exposes the typed 401 redirect error", async () => {
|
||||
const location = { href: "", pathname: "/workspace/chats" };
|
||||
// `search` is always a string on a real Location; the redirect target is
|
||||
// built from pathname + search so the stub has to carry both.
|
||||
const location = { href: "", pathname: "/workspace/chats", search: "" };
|
||||
rs.stubGlobal("window", { location });
|
||||
rs.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user