Compare commits

..

6 Commits

Author SHA1 Message Date
Willem Jiang ff82b7d40f Update the release information of DeerFlow 2026-05-22 22:59:29 +08:00
JeffJiang a8aa69550c Merge branch 'main' into static-website-demo-mode 2026-05-22 22:33:28 +08:00
foreleven bf94ae43fa fix(frontend): address static demo PR review comments 2026-05-22 22:21:07 +08:00
foreleven 55a4149be4 chore(frontend): apply pre-commit formatting 2026-05-22 22:11:14 +08:00
foreleven ff4a3409c7 fix(frontend): render html artifact previews from blob content 2026-05-22 21:50:12 +08:00
foreleven 4450f14928 feat(frontend): support static website demo mode 2026-05-22 21:36:52 +08:00
25 changed files with 477 additions and 145 deletions
-28
View File
@@ -74,25 +74,6 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
os.chmod(file_path, writable_mode, **chmod_kwargs) os.chmod(file_path, writable_mode, **chmod_kwargs)
def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
"""Ensure uploaded files are readable by the sandbox process.
For Docker sandboxes (AIO), the gateway writes files as root with 0o600
permissions, then bind-mounts the host directory into the container. The
sandbox process inside the container runs as a non-root user and cannot
read those files without group/other read bits. This function adds
``S_IRGRP | S_IROTH`` so the sandbox can read the uploaded content.
"""
file_stat = os.lstat(file_path)
if stat.S_ISLNK(file_stat.st_mode):
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
return
readable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IRGRP | stat.S_IROTH
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
os.chmod(file_path, readable_mode, **chmod_kwargs)
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool: def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)) return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False))
@@ -295,15 +276,6 @@ async def upload_files(
_cleanup_uploaded_paths(written_paths) _cleanup_uploaded_paths(written_paths)
raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}")
# When the sandbox uses bind-mounted thread data directories (e.g. AIO with
# LocalContainerBackend), uploaded files are visible inside the container but
# retain the 0o600 permissions set by the gateway. The sandbox process runs
# as a different user and cannot read them. Adjust permissions to add
# group/other read bits so the sandbox can access the files.
if not sync_to_sandbox and getattr(sandbox_provider, "needs_upload_permission_adjustment", True):
for file_path in written_paths:
_make_file_sandbox_readable(file_path)
if sync_to_sandbox: if sync_to_sandbox:
for file_path, virtual_path in sandbox_sync_targets: for file_path, virtual_path in sandbox_sync_targets:
_make_file_sandbox_writable(file_path) _make_file_sandbox_writable(file_path)
@@ -63,7 +63,6 @@ class LocalSandboxProvider(SandboxProvider):
""" """
uses_thread_data_mounts = True uses_thread_data_mounts = True
needs_upload_permission_adjustment = False
def __init__(self, max_cached_threads: int = DEFAULT_MAX_CACHED_THREAD_SANDBOXES): def __init__(self, max_cached_threads: int = DEFAULT_MAX_CACHED_THREAD_SANDBOXES):
"""Initialize the local sandbox provider with static path mappings. """Initialize the local sandbox provider with static path mappings.
@@ -10,7 +10,6 @@ class SandboxProvider(ABC):
"""Abstract base class for sandbox providers""" """Abstract base class for sandbox providers"""
uses_thread_data_mounts: bool = False uses_thread_data_mounts: bool = False
needs_upload_permission_adjustment: bool = True
@abstractmethod @abstractmethod
def acquire(self, thread_id: str | None = None) -> str: def acquire(self, thread_id: str | None = None) -> str:
-56
View File
@@ -219,7 +219,6 @@ def test_upload_files_does_not_adjust_permissions_for_local_sandbox(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.uses_thread_data_mounts = True provider.uses_thread_data_mounts = True
provider.needs_upload_permission_adjustment = False
provider.acquire.return_value = "local" provider.acquire.return_value = "local"
sandbox = MagicMock() sandbox = MagicMock()
provider.get.return_value = sandbox provider.get.return_value = sandbox
@@ -229,14 +228,12 @@ def test_upload_files_does_not_adjust_permissions_for_local_sandbox(tmp_path):
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
patch.object(uploads, "get_sandbox_provider", return_value=provider), patch.object(uploads, "get_sandbox_provider", return_value=provider),
patch.object(uploads, "_make_file_sandbox_writable") as make_writable, patch.object(uploads, "_make_file_sandbox_writable") as make_writable,
patch.object(uploads, "_make_file_sandbox_readable") as make_readable,
): ):
file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads")) file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads"))
result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file], config=SimpleNamespace())) result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file], config=SimpleNamespace()))
assert result.success is True assert result.success is True
make_writable.assert_not_called() make_writable.assert_not_called()
make_readable.assert_not_called()
def test_upload_files_acquires_non_local_sandbox_before_writing(tmp_path): def test_upload_files_acquires_non_local_sandbox_before_writing(tmp_path):
@@ -434,59 +431,6 @@ def test_make_file_sandbox_writable_skips_symlinks(tmp_path):
chmod.assert_not_called() chmod.assert_not_called()
def test_make_file_sandbox_readable_adds_read_bits_for_regular_files(tmp_path):
file_path = tmp_path / "data.csv"
file_path.write_bytes(b"csv-data")
# Simulate the 0o600 permissions set by open_upload_file_no_symlink
file_path.chmod(0o600)
uploads._make_file_sandbox_readable(file_path)
updated_mode = stat.S_IMODE(file_path.stat().st_mode)
assert updated_mode & stat.S_IRUSR
assert updated_mode & stat.S_IRGRP
assert updated_mode & stat.S_IROTH
def test_make_file_sandbox_readable_skips_symlinks(tmp_path):
file_path = tmp_path / "target-link.txt"
file_path.write_text("hello", encoding="utf-8")
symlink_stat = MagicMock(st_mode=stat.S_IFLNK)
with (
patch.object(uploads.os, "lstat", return_value=symlink_stat),
patch.object(uploads.os, "chmod") as chmod,
):
uploads._make_file_sandbox_readable(file_path)
chmod.assert_not_called()
def test_upload_files_adjusts_read_permissions_for_mounted_non_local_sandbox(tmp_path):
thread_uploads_dir = tmp_path / "uploads"
thread_uploads_dir.mkdir(parents=True)
# AIO sandbox with LocalContainerBackend: uses_thread_data_mounts=True
# but needs_upload_permission_adjustment=True (default)
provider = MagicMock()
provider.uses_thread_data_mounts = True
provider.needs_upload_permission_adjustment = True
with (
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
patch.object(uploads, "get_sandbox_provider", return_value=provider),
patch.object(uploads, "_make_file_sandbox_readable") as make_readable,
):
file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads"))
result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-aio", request=MagicMock(), files=[file], config=SimpleNamespace()))
assert result.success is True
make_readable.assert_called_once()
called_path = make_readable.call_args[0][0]
assert called_path.name == "notes.txt"
def test_upload_files_rejects_dotdot_and_dot_filenames(tmp_path): def test_upload_files_rejects_dotdot_and_dot_filenames(tmp_path):
thread_uploads_dir = tmp_path / "uploads" thread_uploads_dir = tmp_path / "uploads"
thread_uploads_dir.mkdir(parents=True) thread_uploads_dir.mkdir(parents=True)
+4
View File
@@ -18,3 +18,7 @@ lint:
format: format:
pnpm format:write 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
+4
View File
@@ -16,6 +16,10 @@ const withNextra = nextra({});
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output:
process.env.NEXT_CONFIG_BUILD_OUTPUT === "standalone"
? "standalone"
: undefined,
i18n: { i18n: {
locales: ["en", "zh"], locales: ["en", "zh"],
defaultLocale: "en", defaultLocale: "en",
@@ -32,7 +32,7 @@ Even with digital Leicas, photographers often emulate film characteristics: natu
### Image 1: Parisian Decisive Moment ### 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. 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 ### 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. 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 ### 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. 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.
@@ -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 { ChatProviders } from "./providers";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context"; export function generateStaticParams() {
if (!isStaticWebsiteOnly()) {
return [];
}
return DEMO_THREAD_IDS.map((thread_id) => ({ thread_id }));
}
export default function ChatLayout({ export default function ChatLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <ChatProviders>{children}</ChatProviders>;
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
} }
@@ -227,6 +227,7 @@ export default function ChatPage() {
isWelcomeMode && <Welcome mode={settings.context.mode} /> isWelcomeMode && <Welcome mode={settings.context.mode} />
} }
disabled={ disabled={
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading isUploading
} }
@@ -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 (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
+8 -6
View File
@@ -43,12 +43,14 @@ export default async function WorkspaceLayout({
> >
Retry Retry
</Link> </Link>
<Link <form action="/api/v1/auth/logout" method="post">
href="/api/v1/auth/logout" <button
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm" type="submit"
> className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
Logout &amp; Reset >
</Link> Logout &amp; Reset
</button>
</form>
</div> </div>
</div> </div>
); );
@@ -83,7 +83,7 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => { const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown"; return language === "html" || language === "markdown";
}, [language]); }, [language]);
const { content } = useArtifactContent({ const { content, url } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile, enabled: isCodeFile && !isWriteFile,
@@ -254,7 +254,9 @@ export function ArtifactFileDetail({
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={displayContent}
isWriteFile={isWriteFile}
language={language ?? "text"} language={language ?? "text"}
url={url}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
@@ -277,27 +279,33 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
isWriteFile,
language, language,
url,
}: { }: {
content: string; content: string;
isWriteFile: boolean;
language: string; language: string;
url?: string;
}) { }) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>(); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => { useEffect(() => {
if (language !== "html") { if (language !== "html" || isWriteFile) {
setHtmlPreviewUrl(undefined); setHtmlPreviewUrl(undefined);
return; return;
} }
const blob = new Blob([content ?? ""], { type: "text/html" }); const blob = new Blob([htmlWithBaseHref(content ?? "", url)], {
const url = URL.createObjectURL(blob); type: "text/html",
setHtmlPreviewUrl(url); });
const objectUrl = URL.createObjectURL(blob);
setHtmlPreviewUrl(objectUrl);
return () => { return () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(objectUrl);
}; };
}, [content, language]); }, [content, isWriteFile, language, url]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
@@ -318,9 +326,35 @@ export function ArtifactFilePreview({
className="size-full" className="size-full"
title="Artifact preview" title="Artifact preview"
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl} src={isWriteFile ? undefined : htmlPreviewUrl}
srcDoc={isWriteFile ? content : undefined}
/> />
); );
} }
return null; return null;
} }
function htmlWithBaseHref(content: string, url?: string) {
if (!url || /<base\s/i.exec(content)) {
return content;
}
const baseHref = htmlBaseHref(url);
const baseElement = `<base href="${escapeHtmlAttribute(baseHref)}">`;
if (/<head[^>]*>/i.exec(content)) {
return content.replace(/<head([^>]*)>/i, `<head$1>${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("&", "&amp;").replaceAll('"', "&quot;");
}
+17 -17
View File
@@ -20,27 +20,27 @@ If you want to understand how DeerFlow works, start with the Introduction. If yo
Start with the conceptual overview first. Start with the conceptual overview first.
- [Introduction](/docs/introduction) - [Introduction](./docs/introduction)
- [Why DeerFlow](/docs/introduction/why-deerflow) - [Why DeerFlow](./docs/introduction/why-deerflow)
- [Harness vs App](/docs/introduction/harness-vs-app) - [Harness vs App](./docs/introduction/harness-vs-app)
### If you want to build with DeerFlow ### 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. 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) - [DeerFlow Harness](./docs/harness)
- [Quick Start](/docs/harness/quick-start) - [Quick Start](./docs/harness/quick-start)
- [Configuration](/docs/harness/configuration) - [Configuration](./docs/harness/configuration)
- [Customization](/docs/harness/customization) - [Customization](./docs/harness/customization)
### If you want to deploy and use DeerFlow ### 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. 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) - [DeerFlow App](./docs/app)
- [Quick Start](/docs/app/quick-start) - [Quick Start](./docs/app/quick-start)
- [Deployment Guide](/docs/app/deployment-guide) - [Deployment Guide](./docs/app/deployment-guide)
- [Workspace Usage](/docs/app/workspace-usage) - [Workspace Usage](./docs/app/workspace-usage)
## Documentation structure ## 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. The Tutorials section is for hands-on, task-oriented learning.
- [Tutorials](/docs/tutorials) - [Tutorials](./docs/tutorials)
### Reference ### Reference
The Reference section is for detailed lookup material, including configuration, runtime modes, APIs, and source-oriented mapping. 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 ## Choose the right path
- If you are **evaluating the project**, start with [Introduction](/docs/introduction). - 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 **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 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 want to **learn by doing**, go to [Tutorials](./docs/tutorials).
@@ -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
+11 -11
View File
@@ -20,27 +20,27 @@ DeerFlow 是一个用于构建和运行 Agent 系统的框架。它提供了一
先从概念概述开始。 先从概念概述开始。
- [简介](/docs/introduction) - [简介](./docs/introduction)
- [为什么选择 DeerFlow](/docs/introduction/why-deerflow) - [为什么选择 DeerFlow](./docs/introduction/why-deerflow)
- [Harness 与应用的区别](/docs/introduction/harness-vs-app) - [Harness 与应用的区别](./docs/introduction/harness-vs-app)
### 如果你想基于 DeerFlow 进行开发 ### 如果你想基于 DeerFlow 进行开发
从 Harness 章节开始。这条路径适合想将 DeerFlow 功能集成到自己系统中,或基于 DeerFlow 运行时构建自定义 Agent 产品的团队。 从 Harness 章节开始。这条路径适合想将 DeerFlow 功能集成到自己系统中,或基于 DeerFlow 运行时构建自定义 Agent 产品的团队。
- [DeerFlow Harness](/docs/harness) - [DeerFlow Harness](./docs/harness)
- [快速上手](/docs/harness/quick-start) - [快速上手](./docs/harness/quick-start)
- [配置](/docs/harness/configuration) - [配置](./docs/harness/configuration)
- [自定义与扩展](/docs/harness/customization) - [自定义与扩展](./docs/harness/customization)
### 如果你想部署和使用 DeerFlow ### 如果你想部署和使用 DeerFlow
从应用章节开始。这条路径适合想将 DeerFlow 作为完整应用运行,并了解如何配置、运维和实际使用的团队。 从应用章节开始。这条路径适合想将 DeerFlow 作为完整应用运行,并了解如何配置、运维和实际使用的团队。
- [DeerFlow 应用](/docs/application) - [DeerFlow 应用](./docs/application)
- [快速上手](/docs/application/quick-start) - [快速上手](./docs/application/quick-start)
- [部署指南](/docs/application/deployment-guide) - [部署指南](./docs/application/deployment-guide)
- [工作区使用](/docs/application/workspace-usage) - [工作区使用](./docs/application/workspace-usage)
## 文档结构 ## 文档结构
+49
View File
@@ -3,6 +3,13 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config"; 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 { isStateChangingMethod, readCsrfCookie } from "./fetcher";
import { sanitizeRunStreamOptions } from "./stream-mode"; import { sanitizeRunStreamOptions } from "./stream-mode";
@@ -32,6 +39,10 @@ function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit {
} }
function createCompatibleClient(isMock?: boolean): LangGraphClient { function createCompatibleClient(isMock?: boolean): LangGraphClient {
if (isStaticWebsiteOnly() && !isMock) {
return createStaticClient();
}
const apiUrl = getLangGraphBaseURL(isMock); const apiUrl = getLangGraphBaseURL(isMock);
console.log(`Creating API client with base URL: ${apiUrl}`); console.log(`Creating API client with base URL: ${apiUrl}`);
const client = new LangGraphClient({ const client = new LangGraphClient({
@@ -58,6 +69,44 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient {
return client; 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<AgentThreadState>;
}
const _clients = new Map<string, LangGraphClient>(); const _clients = new Map<string, LangGraphClient>();
export function getAPIClient(isMock?: boolean): LangGraphClient { export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default"; const cacheKey = isMock ? "mock" : "default";
+20
View File
@@ -1,4 +1,5 @@
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
import { isStaticWebsiteOnly } from "../static-mode";
import type { AgentThread } from "../threads"; import type { AgentThread } from "../threads";
export function urlOfArtifact({ export function urlOfArtifact({
@@ -12,6 +13,9 @@ export function urlOfArtifact({
download?: boolean; download?: boolean;
isMock?: boolean; isMock?: boolean;
}) { }) {
if (isStaticWebsiteOnly()) {
return staticDemoArtifactURL({ filepath, threadId, download });
}
if (isMock) { if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`; 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) { export function resolveArtifactURL(absolutePath: string, threadId: string) {
if (isStaticWebsiteOnly()) {
return staticDemoArtifactURL({ filepath: absolutePath, threadId });
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`; 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" : ""}`;
}
+17 -3
View File
@@ -10,6 +10,8 @@ import React, {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { isStaticWebsiteOnly } from "../static-mode";
import { type User, buildLoginUrl } from "./types"; import { type User, buildLoginUrl } from "./types";
// Re-export for consumers // Re-export for consumers
@@ -46,6 +48,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const staticMode = isStaticWebsiteOnly();
const isAuthenticated = user !== null; 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) * Used when initialUser might be stale (e.g., after tab was inactive)
*/ */
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
if (staticMode) return;
try { try {
setIsLoading(true); setIsLoading(true);
const res = await fetch("/api/v1/auth/me", { const res = await fetch("/api/v1/auth/me", {
@@ -77,7 +82,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [pathname, router]); }, [staticMode, pathname, router]);
/** /**
* Logout - call FastAPI logout endpoint and clear local state * 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 // Immediately clear local state to prevent UI flicker
setUser(null); setUser(null);
if (staticMode) {
router.push("/");
return;
}
try { try {
await fetch("/api/v1/auth/logout", { await fetch("/api/v1/auth/logout", {
method: "POST", method: "POST",
@@ -99,7 +109,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
// Redirect to home page // Redirect to home page
router.push("/"); router.push("/");
}, [router]); }, [staticMode, router]);
/** /**
* Handle visibility change - refresh user when tab becomes visible again. * 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); const lastCheckRef = React.useRef(0);
useEffect(() => { useEffect(() => {
if (staticMode) return;
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState !== "visible" || user === null) return; if (document.visibilityState !== "visible" || user === null) return;
const now = Date.now(); const now = Date.now();
@@ -120,7 +132,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
return () => { return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [user, refreshUser]); }, [staticMode, user, refreshUser]);
const value: AuthContextType = { const value: AuthContextType = {
user, user,
@@ -155,6 +167,8 @@ export function useRequireAuth(): AuthContextType {
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
if (isStaticWebsiteOnly()) return;
// Only redirect if we're sure user is not authenticated (not just loading) // Only redirect if we're sure user is not authenticated (not just loading)
if (!auth.isLoading && !auth.isAuthenticated) { if (!auth.isLoading && !auth.isAuthenticated) {
router.push(buildLoginUrl(pathname || "/workspace")); router.push(buildLoginUrl(pathname || "/workspace"));
+10
View File
@@ -1,6 +1,9 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode";
import { getGatewayConfig } from "./gateway-config"; import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types"; import { type AuthResult, userSchema } from "./types";
const SSR_AUTH_TIMEOUT_MS = 5_000; 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. * Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
*/ */
export async function getServerSideUser(): Promise<AuthResult> { export async function getServerSideUser(): Promise<AuthResult> {
if (isStaticWebsiteOnly()) {
return {
tag: "authenticated",
user: STATIC_WEBSITE_USER,
};
}
if (process.env.DEER_FLOW_AUTH_DISABLED === "1") { if (process.env.DEER_FLOW_AUTH_DISABLED === "1") {
return { return {
tag: "authenticated", tag: "authenticated",
+8
View File
@@ -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,
};
+10
View File
@@ -1,8 +1,18 @@
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
import { isStaticWebsiteOnly } from "../static-mode";
import type { ModelsResponse } from "./types"; import type { ModelsResponse } from "./types";
const STATIC_MODELS_RESPONSE: ModelsResponse = {
models: [],
token_usage: { enabled: false },
};
export async function loadModels(): Promise<ModelsResponse> { export async function loadModels(): Promise<ModelsResponse> {
if (isStaticWebsiteOnly()) {
return STATIC_MODELS_RESPONSE;
}
const res = await fetch(`${getBackendBaseURL()}/api/models`); const res = await fetch(`${getBackendBaseURL()}/api/models`);
const data = (await res.json()) as Partial<ModelsResponse>; const data = (await res.json()) as Partial<ModelsResponse>;
return { return {
+5
View File
@@ -0,0 +1,5 @@
import { env } from "@/env";
export function isStaticWebsiteOnly() {
return env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
}
+87
View File
@@ -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<ThreadsClient["search"]>[0]
>;
export async function loadStaticDemoThreads(
params: ThreadSearchParams = {},
): Promise<AgentThread[]> {
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<string, unknown>)[sortBy];
const bTimestamp = (b as unknown as Record<string, unknown>)[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<AgentThread> {
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<AgentThreadState> {
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: [],
};
}
@@ -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<string, string | undefined>;
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");
});
});
@@ -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<string, string | undefined>;
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();
});
});