diff --git a/frontend/Makefile b/frontend/Makefile
index 48d23b97b..bf6c351e2 100644
--- a/frontend/Makefile
+++ b/frontend/Makefile
@@ -18,3 +18,7 @@ lint:
format:
pnpm format:write
+
+build-static:
+ NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build
+ @if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 5b20aad5f..7007d59fc 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -16,6 +16,10 @@ const withNextra = nextra({});
/** @type {import("next").NextConfig} */
const config = {
+ output:
+ process.env.NEXT_CONFIG_BUILD_OUTPUT === "standalone"
+ ? "standalone"
+ : undefined,
i18n: {
locales: ["en", "zh"],
defaultLocale: "en",
diff --git a/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-master-photography-article.md b/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-master-photography-article.md
index 6735fb56f..75e82aec4 100644
--- a/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-master-photography-article.md
+++ b/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-master-photography-article.md
@@ -32,7 +32,7 @@ Even with digital Leicas, photographers often emulate film characteristics: natu
### Image 1: Parisian Decisive Moment
-
+
This image captures the essence of Cartier-Bresson's philosophy. A woman in a red coat leaps over a puddle while a cyclist passes in perfect synchrony. The composition follows the rule of thirds, with the subject positioned at the intersection of grid lines. Shot with a simulated Leica M11 and 35mm Summicron lens at f/2.8, the image features shallow depth of field, natural film grain, and the warm, muted color palette characteristic of Leica photography.
@@ -40,7 +40,7 @@ The "decisive moment" here isn't just about timing—it's about the alignment of
### Image 2: Tokyo Night Reflections
-
+
Moving to Shinjuku, Tokyo, this image explores the atmospheric possibilities of Leica's legendary Noctilux lens. Simulating a Leica M10-P with a 50mm f/0.95 Noctilux wide open, the image creates extremely shallow depth of field with beautiful bokeh balls from neon signs reflected in wet pavement.
@@ -48,7 +48,7 @@ A salaryman waits under glowing kanji signs, steam rising from a nearby ramen sh
### Image 3: New York City Candid
-
+
This Chinatown scene demonstrates the documentary power of Leica's Q2 camera with its fixed 28mm Summilux lens. The wide angle captures environmental context while maintaining intimate proximity to the subjects. A fishmonger hands a live fish to a customer while tourists photograph the scene—a moment of cultural contrast and authentic urban life.
diff --git a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
index 877103774..eeee68347 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
@@ -1,19 +1,19 @@
-"use client";
+import { isStaticWebsiteOnly } from "@/core/static-mode";
+import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
-import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
-import { ArtifactsProvider } from "@/components/workspace/artifacts";
-import { SubtasksProvider } from "@/core/tasks/context";
+import { ChatProviders } from "./providers";
+
+export function generateStaticParams() {
+ if (!isStaticWebsiteOnly()) {
+ return [];
+ }
+ return DEMO_THREAD_IDS.map((thread_id) => ({ thread_id }));
+}
export default function ChatLayout({
children,
}: {
children: React.ReactNode;
}) {
- return (
-
-
- {children}
-
-
- );
+ return {children};
}
diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
index 6f865ade8..ce3912b91 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
@@ -227,6 +227,7 @@ export default function ChatPage() {
isWelcomeMode &&
}
disabled={
+ isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
diff --git a/frontend/src/app/workspace/chats/[thread_id]/providers.tsx b/frontend/src/app/workspace/chats/[thread_id]/providers.tsx
new file mode 100644
index 000000000..46d4a4cef
--- /dev/null
+++ b/frontend/src/app/workspace/chats/[thread_id]/providers.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
+import { ArtifactsProvider } from "@/components/workspace/artifacts";
+import { SubtasksProvider } from "@/core/tasks/context";
+
+export function ChatProviders({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index c2d567339..0d214f0d3 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -43,12 +43,14 @@ export default async function WorkspaceLayout({
>
Retry
-
- Logout & Reset
-
+
);
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
index 17e642fc5..93130c44f 100644
--- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -83,7 +83,7 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
- const { content } = useArtifactContent({
+ const { content, url } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
@@ -254,7 +254,9 @@ export function ArtifactFileDetail({
(language === "markdown" || language === "html") && (
)}
{isCodeFile && viewMode === "code" && (
@@ -277,27 +279,33 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({
content,
+ isWriteFile,
language,
+ url,
}: {
content: string;
+ isWriteFile: boolean;
language: string;
+ url?: string;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState();
useEffect(() => {
- if (language !== "html") {
+ if (language !== "html" || isWriteFile) {
setHtmlPreviewUrl(undefined);
return;
}
- const blob = new Blob([content ?? ""], { type: "text/html" });
- const url = URL.createObjectURL(blob);
- setHtmlPreviewUrl(url);
+ const blob = new Blob([htmlWithBaseHref(content ?? "", url)], {
+ type: "text/html",
+ });
+ const objectUrl = URL.createObjectURL(blob);
+ setHtmlPreviewUrl(objectUrl);
return () => {
- URL.revokeObjectURL(url);
+ URL.revokeObjectURL(objectUrl);
};
- }, [content, language]);
+ }, [content, isWriteFile, language, url]);
if (language === "markdown") {
return (
@@ -318,9 +326,35 @@ export function ArtifactFilePreview({
className="size-full"
title="Artifact preview"
sandbox="allow-scripts allow-forms"
- src={htmlPreviewUrl}
+ src={isWriteFile ? undefined : htmlPreviewUrl}
+ srcDoc={isWriteFile ? content : undefined}
/>
);
}
return null;
}
+
+function htmlWithBaseHref(content: string, url?: string) {
+ if (!url || /`;
+ if (/]*>/i.exec(content)) {
+ return content.replace(/]*)>/i, `${baseElement}`);
+ }
+ return `${baseElement}${content}`;
+}
+
+function htmlBaseHref(url: string) {
+ const baseUrl = new URL(url, window.location.href);
+ baseUrl.pathname = baseUrl.pathname.replace(/\/[^/]*$/, "/");
+ baseUrl.search = "";
+ baseUrl.hash = "";
+ return baseUrl.toString();
+}
+
+function escapeHtmlAttribute(value: string) {
+ return value.replaceAll("&", "&").replaceAll('"', """);
+}
diff --git a/frontend/src/content/en/index.mdx b/frontend/src/content/en/index.mdx
index 0dd2efe12..e289d8f3b 100644
--- a/frontend/src/content/en/index.mdx
+++ b/frontend/src/content/en/index.mdx
@@ -20,27 +20,27 @@ If you want to understand how DeerFlow works, start with the Introduction. If yo
Start with the conceptual overview first.
-- [Introduction](/docs/introduction)
-- [Why DeerFlow](/docs/introduction/why-deerflow)
-- [Harness vs App](/docs/introduction/harness-vs-app)
+- [Introduction](./docs/introduction)
+- [Why DeerFlow](./docs/introduction/why-deerflow)
+- [Harness vs App](./docs/introduction/harness-vs-app)
### If you want to build with DeerFlow
Start with the Harness section. This path is for teams who want to integrate DeerFlow capabilities into their own system or build a custom agent product on top of the DeerFlow runtime.
-- [DeerFlow Harness](/docs/harness)
-- [Quick Start](/docs/harness/quick-start)
-- [Configuration](/docs/harness/configuration)
-- [Customization](/docs/harness/customization)
+- [DeerFlow Harness](./docs/harness)
+- [Quick Start](./docs/harness/quick-start)
+- [Configuration](./docs/harness/configuration)
+- [Customization](./docs/harness/customization)
### If you want to deploy and use DeerFlow
Start with the App section. This path is for teams who want to run DeerFlow as a complete application and understand how to configure, operate, and use it in practice.
-- [DeerFlow App](/docs/app)
-- [Quick Start](/docs/app/quick-start)
-- [Deployment Guide](/docs/app/deployment-guide)
-- [Workspace Usage](/docs/app/workspace-usage)
+- [DeerFlow App](./docs/app)
+- [Quick Start](./docs/app/quick-start)
+- [Deployment Guide](./docs/app/deployment-guide)
+- [Workspace Usage](./docs/app/workspace-usage)
## Documentation structure
@@ -79,17 +79,17 @@ The App section is written for teams who want to deploy DeerFlow as a usable pro
The Tutorials section is for hands-on, task-oriented learning.
-- [Tutorials](/docs/tutorials)
+- [Tutorials](./docs/tutorials)
### Reference
The Reference section is for detailed lookup material, including configuration, runtime modes, APIs, and source-oriented mapping.
-- [Reference](/docs/reference)
+- [Reference](./docs/reference)
## Choose the right path
-- If you are **evaluating the project**, start with [Introduction](/docs/introduction).
-- If you are **building your own agent system**, start with [DeerFlow Harness](/docs/harness).
-- If you are **deploying DeerFlow for users**, start with [DeerFlow App](/docs/app).
-- If you want to **learn by doing**, go to [Tutorials](/docs/tutorials).
+- If you are **evaluating the project**, start with [Introduction](./docs/introduction).
+- If you are **building your own agent system**, start with [DeerFlow Harness](./docs/harness).
+- If you are **deploying DeerFlow for users**, start with [DeerFlow App](./docs/app).
+- If you want to **learn by doing**, go to [Tutorials](./docs/tutorials).
diff --git a/frontend/src/content/en/posts/releases/2_0_rc.mdx b/frontend/src/content/en/posts/releases/2_0_rc.mdx
new file mode 100644
index 000000000..1f5f347c4
--- /dev/null
+++ b/frontend/src/content/en/posts/releases/2_0_rc.mdx
@@ -0,0 +1,9 @@
+---
+title: DeerFlow 2.0 M1
+description: DeerFlow 2.0 M1 is officially in RC. Here's what you need to know.
+date: 2026-05-30
+tags:
+ - Release
+---
+
+## DeerFlow 2.0 M1 Release
diff --git a/frontend/src/content/zh/index.mdx b/frontend/src/content/zh/index.mdx
index 5f2a18deb..912991b06 100644
--- a/frontend/src/content/zh/index.mdx
+++ b/frontend/src/content/zh/index.mdx
@@ -20,27 +20,27 @@ DeerFlow 是一个用于构建和运行 Agent 系统的框架。它提供了一
先从概念概述开始。
-- [简介](/docs/introduction)
-- [为什么选择 DeerFlow](/docs/introduction/why-deerflow)
-- [Harness 与应用的区别](/docs/introduction/harness-vs-app)
+- [简介](./docs/introduction)
+- [为什么选择 DeerFlow](./docs/introduction/why-deerflow)
+- [Harness 与应用的区别](./docs/introduction/harness-vs-app)
### 如果你想基于 DeerFlow 进行开发
从 Harness 章节开始。这条路径适合想将 DeerFlow 功能集成到自己系统中,或基于 DeerFlow 运行时构建自定义 Agent 产品的团队。
-- [DeerFlow Harness](/docs/harness)
-- [快速上手](/docs/harness/quick-start)
-- [配置](/docs/harness/configuration)
-- [自定义与扩展](/docs/harness/customization)
+- [DeerFlow Harness](./docs/harness)
+- [快速上手](./docs/harness/quick-start)
+- [配置](./docs/harness/configuration)
+- [自定义与扩展](./docs/harness/customization)
### 如果你想部署和使用 DeerFlow
从应用章节开始。这条路径适合想将 DeerFlow 作为完整应用运行,并了解如何配置、运维和实际使用的团队。
-- [DeerFlow 应用](/docs/application)
-- [快速上手](/docs/application/quick-start)
-- [部署指南](/docs/application/deployment-guide)
-- [工作区使用](/docs/application/workspace-usage)
+- [DeerFlow 应用](./docs/application)
+- [快速上手](./docs/application/quick-start)
+- [部署指南](./docs/application/deployment-guide)
+- [工作区使用](./docs/application/workspace-usage)
## 文档结构
diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts
index 0b4532ca9..841c2cdfb 100644
--- a/frontend/src/core/api/api-client.ts
+++ b/frontend/src/core/api/api-client.ts
@@ -3,6 +3,13 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config";
+import { isStaticWebsiteOnly } from "../static-mode";
+import {
+ loadStaticDemoThread,
+ loadStaticDemoThreads,
+ staticDemoThreadState,
+} from "../threads/static-demo";
+import type { AgentThreadState } from "../threads/types";
import { isStateChangingMethod, readCsrfCookie } from "./fetcher";
import { sanitizeRunStreamOptions } from "./stream-mode";
@@ -32,6 +39,10 @@ function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit {
}
function createCompatibleClient(isMock?: boolean): LangGraphClient {
+ if (isStaticWebsiteOnly() && !isMock) {
+ return createStaticClient();
+ }
+
const apiUrl = getLangGraphBaseURL(isMock);
console.log(`Creating API client with base URL: ${apiUrl}`);
const client = new LangGraphClient({
@@ -58,6 +69,44 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient {
return client;
}
+function createStaticClient(): LangGraphClient {
+ const apiUrl =
+ typeof window === "undefined"
+ ? "http://localhost:3000"
+ : window.location.origin;
+ const client = new LangGraphClient({ apiUrl });
+
+ client.threads.search = (async (query) => {
+ return loadStaticDemoThreads(query);
+ }) as typeof client.threads.search;
+
+ client.threads.get = (async (threadId) => {
+ return loadStaticDemoThread(threadId);
+ }) as typeof client.threads.get;
+
+ client.threads.getState = (async (threadId) => {
+ return staticDemoThreadState(await loadStaticDemoThread(threadId));
+ }) as typeof client.threads.getState;
+
+ client.threads.getHistory = (async (threadId) => {
+ return [staticDemoThreadState(await loadStaticDemoThread(threadId))];
+ }) as typeof client.threads.getHistory;
+
+ client.threads.update = (async (threadId) => {
+ return loadStaticDemoThread(threadId);
+ }) as typeof client.threads.update;
+
+ client.runs.list = (async () => []) as typeof client.runs.list;
+ client.runs.stream = async function* () {
+ /* empty */
+ } as typeof client.runs.stream;
+ client.runs.joinStream = async function* () {
+ /* empty */
+ } as typeof client.runs.joinStream;
+
+ return client as LangGraphClient;
+}
+
const _clients = new Map();
export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default";
diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts
index 402696504..e205b739a 100644
--- a/frontend/src/core/artifacts/utils.ts
+++ b/frontend/src/core/artifacts/utils.ts
@@ -1,4 +1,5 @@
import { getBackendBaseURL } from "../config";
+import { isStaticWebsiteOnly } from "../static-mode";
import type { AgentThread } from "../threads";
export function urlOfArtifact({
@@ -12,6 +13,9 @@ export function urlOfArtifact({
download?: boolean;
isMock?: boolean;
}) {
+ if (isStaticWebsiteOnly()) {
+ return staticDemoArtifactURL({ filepath, threadId, download });
+ }
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
@@ -23,5 +27,21 @@ export function extractArtifactsFromThread(thread: AgentThread) {
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
+ if (isStaticWebsiteOnly()) {
+ return staticDemoArtifactURL({ filepath: absolutePath, threadId });
+ }
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
+
+function staticDemoArtifactURL({
+ filepath,
+ threadId,
+ download = false,
+}: {
+ filepath: string;
+ threadId: string;
+ download?: boolean;
+}) {
+ const demoPath = filepath.replace(/^\/mnt\//, "/");
+ return `${getBackendBaseURL()}/demo/threads/${threadId}${demoPath}${download ? "?download=true" : ""}`;
+}
diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx
index 652cc49b8..5824c5f7b 100644
--- a/frontend/src/core/auth/AuthProvider.tsx
+++ b/frontend/src/core/auth/AuthProvider.tsx
@@ -10,6 +10,8 @@ import React, {
type ReactNode,
} from "react";
+import { isStaticWebsiteOnly } from "../static-mode";
+
import { type User, buildLoginUrl } from "./types";
// Re-export for consumers
@@ -46,6 +48,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const pathname = usePathname();
+ const staticMode = isStaticWebsiteOnly();
const isAuthenticated = user !== null;
@@ -54,6 +57,8 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
* Used when initialUser might be stale (e.g., after tab was inactive)
*/
const refreshUser = useCallback(async () => {
+ if (staticMode) return;
+
try {
setIsLoading(true);
const res = await fetch("/api/v1/auth/me", {
@@ -77,7 +82,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
} finally {
setIsLoading(false);
}
- }, [pathname, router]);
+ }, [staticMode, pathname, router]);
/**
* Logout - call FastAPI logout endpoint and clear local state
@@ -87,6 +92,11 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
// Immediately clear local state to prevent UI flicker
setUser(null);
+ if (staticMode) {
+ router.push("/");
+ return;
+ }
+
try {
await fetch("/api/v1/auth/logout", {
method: "POST",
@@ -99,7 +109,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
// Redirect to home page
router.push("/");
- }, [router]);
+ }, [staticMode, router]);
/**
* Handle visibility change - refresh user when tab becomes visible again.
@@ -108,6 +118,8 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const lastCheckRef = React.useRef(0);
useEffect(() => {
+ if (staticMode) return;
+
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible" || user === null) return;
const now = Date.now();
@@ -120,7 +132,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
- }, [user, refreshUser]);
+ }, [staticMode, user, refreshUser]);
const value: AuthContextType = {
user,
@@ -155,6 +167,8 @@ export function useRequireAuth(): AuthContextType {
const pathname = usePathname();
useEffect(() => {
+ if (isStaticWebsiteOnly()) return;
+
// Only redirect if we're sure user is not authenticated (not just loading)
if (!auth.isLoading && !auth.isAuthenticated) {
router.push(buildLoginUrl(pathname || "/workspace"));
diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts
index 6ca3195c4..5712f1e89 100644
--- a/frontend/src/core/auth/server.ts
+++ b/frontend/src/core/auth/server.ts
@@ -1,6 +1,9 @@
import { cookies } from "next/headers";
+import { isStaticWebsiteOnly } from "../static-mode";
+
import { getGatewayConfig } from "./gateway-config";
+import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
const SSR_AUTH_TIMEOUT_MS = 5_000;
@@ -10,6 +13,13 @@ const SSR_AUTH_TIMEOUT_MS = 5_000;
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
*/
export async function getServerSideUser(): Promise {
+ if (isStaticWebsiteOnly()) {
+ return {
+ tag: "authenticated",
+ user: STATIC_WEBSITE_USER,
+ };
+ }
+
if (process.env.DEER_FLOW_AUTH_DISABLED === "1") {
return {
tag: "authenticated",
diff --git a/frontend/src/core/auth/static-user.ts b/frontend/src/core/auth/static-user.ts
new file mode 100644
index 000000000..31615e1d4
--- /dev/null
+++ b/frontend/src/core/auth/static-user.ts
@@ -0,0 +1,8 @@
+import type { User } from "./types";
+
+export const STATIC_WEBSITE_USER: User = {
+ id: "static-website-user",
+ email: "static@example.local",
+ system_role: "admin",
+ needs_setup: false,
+};
diff --git a/frontend/src/core/models/api.ts b/frontend/src/core/models/api.ts
index 46675bf6d..d924e3529 100644
--- a/frontend/src/core/models/api.ts
+++ b/frontend/src/core/models/api.ts
@@ -1,8 +1,18 @@
import { getBackendBaseURL } from "../config";
+import { isStaticWebsiteOnly } from "../static-mode";
import type { ModelsResponse } from "./types";
+const STATIC_MODELS_RESPONSE: ModelsResponse = {
+ models: [],
+ token_usage: { enabled: false },
+};
+
export async function loadModels(): Promise {
+ if (isStaticWebsiteOnly()) {
+ return STATIC_MODELS_RESPONSE;
+ }
+
const res = await fetch(`${getBackendBaseURL()}/api/models`);
const data = (await res.json()) as Partial;
return {
diff --git a/frontend/src/core/static-mode.ts b/frontend/src/core/static-mode.ts
new file mode 100644
index 000000000..2d035f128
--- /dev/null
+++ b/frontend/src/core/static-mode.ts
@@ -0,0 +1,5 @@
+import { env } from "@/env";
+
+export function isStaticWebsiteOnly() {
+ return env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
+}
diff --git a/frontend/src/core/threads/static-demo.ts b/frontend/src/core/threads/static-demo.ts
new file mode 100644
index 000000000..93c8c1c53
--- /dev/null
+++ b/frontend/src/core/threads/static-demo.ts
@@ -0,0 +1,87 @@
+import type { ThreadState } from "@langchain/langgraph-sdk";
+import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
+
+import type { AgentThread, AgentThreadState } from "./types";
+
+export const DEMO_THREAD_IDS = [
+ "21cfea46-34bd-4aa6-9e1f-3009452fbeb9",
+ "3823e443-4e2b-4679-b496-a9506eae462b",
+ "4f3e55ee-f853-43db-bfb3-7d1a411f03cb",
+ "5aa47db1-d0cb-4eb9-aea5-3dac1b371c5a",
+ "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
+ "7f9dc56c-e49c-4671-a3d2-c492ff4dce0c",
+ "90040b36-7eba-4b97-ba89-02c3ad47a8b9",
+ "ad76c455-5bf9-4335-8517-fc03834ab828",
+ "b83fbb2a-4e36-4d82-9de0-7b2a02c2092a",
+ "c02bb4d5-4202-490e-ae8f-ff4864fc0d2e",
+ "d3e5adaf-084c-4dd5-9d29-94f1d6bccd98",
+ "f4125791-0128-402a-8ca9-50e0947557e4",
+ "fe3f7974-1bcb-4a01-a950-79673baafefd",
+] as const;
+
+export type ThreadSearchParams = NonNullable<
+ Parameters[0]
+>;
+
+export async function loadStaticDemoThreads(
+ params: ThreadSearchParams = {},
+): Promise {
+ const threads = await Promise.all(
+ DEMO_THREAD_IDS.map((threadId) => loadStaticDemoThread(threadId)),
+ );
+
+ const sortBy = params.sortBy ?? "updated_at";
+ const sortOrder = params.sortOrder ?? "desc";
+ const sortedThreads = [...threads].sort((a, b) => {
+ const aTimestamp = (a as unknown as Record)[sortBy];
+ const bTimestamp = (b as unknown as Record)[sortBy];
+ const aParsed = typeof aTimestamp === "string" ? Date.parse(aTimestamp) : 0;
+ const bParsed = typeof bTimestamp === "string" ? Date.parse(bTimestamp) : 0;
+ const aValue = Number.isNaN(aParsed) ? 0 : aParsed;
+ const bValue = Number.isNaN(bParsed) ? 0 : bParsed;
+ return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
+ });
+
+ const offset = Math.max(0, Math.floor(params.offset ?? 0));
+ const limit =
+ typeof params.limit === "number"
+ ? Math.max(0, Math.floor(params.limit))
+ : sortedThreads.length;
+ return sortedThreads.slice(offset, offset + limit);
+}
+
+export async function loadStaticDemoThread(
+ threadId: string,
+): Promise {
+ const response = await globalThis.fetch(
+ `/demo/threads/${encodeURIComponent(threadId)}/thread.json`,
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to load demo thread ${threadId}`);
+ }
+ const thread = (await response.json()) as AgentThread;
+ return {
+ ...thread,
+ thread_id: threadId,
+ updated_at: thread.updated_at ?? thread.created_at,
+ };
+}
+
+export function staticDemoThreadState(
+ thread: AgentThread,
+): ThreadState {
+ return {
+ values: thread.values,
+ next: [],
+ checkpoint: {
+ thread_id: thread.thread_id,
+ checkpoint_ns: "",
+ checkpoint_id: null,
+ checkpoint_map: null,
+ },
+ metadata: thread.metadata ?? null,
+ created_at: thread.updated_at ?? thread.created_at ?? null,
+ parent_checkpoint: null,
+ tasks: [],
+ };
+}
diff --git a/frontend/tests/unit/core/artifacts/utils.test.ts b/frontend/tests/unit/core/artifacts/utils.test.ts
new file mode 100644
index 000000000..c0400b371
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/utils.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+const ENV_KEYS = [
+ "NEXT_PUBLIC_BACKEND_BASE_URL",
+ "NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
+] as const;
+
+type EnvSnapshot = Partial<
+ Record<(typeof ENV_KEYS)[number], string | undefined>
+>;
+
+function snapshotEnv(): EnvSnapshot {
+ const snapshot: EnvSnapshot = {};
+ for (const key of ENV_KEYS) {
+ snapshot[key] = process.env[key];
+ }
+ return snapshot;
+}
+
+function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
+ const env = process.env as Record;
+ if (value === undefined) {
+ delete env[key];
+ } else {
+ env[key] = value;
+ }
+}
+
+function restoreEnv(snapshot: EnvSnapshot) {
+ for (const key of ENV_KEYS) {
+ setEnv(key, snapshot[key]);
+ }
+}
+
+async function loadFreshArtifactUtils() {
+ vi.resetModules();
+ return await import("@/core/artifacts/utils");
+}
+
+describe("artifact URL helpers", () => {
+ let saved: EnvSnapshot;
+
+ beforeEach(() => {
+ saved = snapshotEnv();
+ setEnv("NEXT_PUBLIC_BACKEND_BASE_URL", undefined);
+ setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
+ });
+
+ afterEach(() => {
+ restoreEnv(saved);
+ });
+
+ test("maps static demo artifact paths to bundled public files", async () => {
+ setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
+
+ const { resolveArtifactURL, urlOfArtifact } =
+ await loadFreshArtifactUtils();
+
+ expect(
+ urlOfArtifact({
+ filepath: "/mnt/user-data/outputs/index.html",
+ threadId: "thread-1",
+ }),
+ ).toBe("/demo/threads/thread-1/user-data/outputs/index.html");
+ expect(
+ resolveArtifactURL("/mnt/user-data/outputs/style.css", "thread-1"),
+ ).toBe("/demo/threads/thread-1/user-data/outputs/style.css");
+ });
+});
diff --git a/frontend/tests/unit/core/auth/server.test.ts b/frontend/tests/unit/core/auth/server.test.ts
new file mode 100644
index 000000000..fea6ef830
--- /dev/null
+++ b/frontend/tests/unit/core/auth/server.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
+
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(() => {
+ throw new Error("cookies should not be read in static website mode");
+ }),
+}));
+
+const ENV_KEYS = [
+ "DEER_FLOW_AUTH_DISABLED",
+ "NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
+] as const;
+
+type EnvSnapshot = Partial<
+ Record<(typeof ENV_KEYS)[number], string | undefined>
+>;
+
+function snapshotEnv(): EnvSnapshot {
+ const snapshot: EnvSnapshot = {};
+ for (const key of ENV_KEYS) {
+ snapshot[key] = process.env[key];
+ }
+ return snapshot;
+}
+
+function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
+ const env = process.env as Record;
+ if (value === undefined) {
+ delete env[key];
+ } else {
+ env[key] = value;
+ }
+}
+
+function restoreEnv(snapshot: EnvSnapshot) {
+ for (const key of ENV_KEYS) {
+ setEnv(key, snapshot[key]);
+ }
+}
+
+async function loadFreshServerAuth() {
+ vi.resetModules();
+ return await import("@/core/auth/server");
+}
+
+describe("getServerSideUser", () => {
+ let saved: EnvSnapshot;
+
+ beforeEach(() => {
+ saved = snapshotEnv();
+ setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
+ setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
+ });
+
+ afterEach(() => {
+ restoreEnv(saved);
+ vi.unstubAllGlobals();
+ });
+
+ test("bypasses gateway auth in static website mode", async () => {
+ setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
+ const fetchSpy = vi.fn(() => {
+ throw new Error("fetch should not be called in static website mode");
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const { getServerSideUser } = await loadFreshServerAuth();
+
+ await expect(getServerSideUser()).resolves.toEqual({
+ tag: "authenticated",
+ user: STATIC_WEBSITE_USER,
+ });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});