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 -![Paris Decisive Moment](/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-paris-decisive-moment.jpg) +![Paris Decisive Moment](/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-paris-decisive-moment.jpg) 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 -![Tokyo Night Scene](/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-tokyo-night.jpg) +![Tokyo Night Scene](/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-tokyo-night.jpg) 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 -![NYC Candid Scene](/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-nyc-candid.jpg) +![NYC Candid Scene](/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/user-data/outputs/leica-nyc-candid.jpg) 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(); + }); +});