mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-22 16:06:50 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd9c61b9a | |||
| 4731605d99 |
@@ -74,6 +74,25 @@ 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))
|
||||||
|
|
||||||
@@ -276,6 +295,15 @@ 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,6 +63,7 @@ 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,6 +10,7 @@ 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:
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ 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
|
||||||
@@ -228,12 +229,14 @@ 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):
|
||||||
@@ -431,6 +434,59 @@ 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)
|
||||||
|
|||||||
@@ -18,7 +18,3 @@ 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
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ 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",
|
||||||
|
|||||||
+3
-3
@@ -32,7 +32,7 @@ Even with digital Leicas, photographers often emulate film characteristics: natu
|
|||||||
|
|
||||||
### Image 1: Parisian Decisive Moment
|
### 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.
|
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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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 @@
|
|||||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
"use client";
|
||||||
import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
|
|
||||||
|
|
||||||
import { ChatProviders } from "./providers";
|
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||||
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
export function generateStaticParams() {
|
import { SubtasksProvider } from "@/core/tasks/context";
|
||||||
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 <ChatProviders>{children}</ChatProviders>;
|
return (
|
||||||
|
<SubtasksProvider>
|
||||||
|
<ArtifactsProvider>
|
||||||
|
<PromptInputProvider>{children}</PromptInputProvider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
</SubtasksProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -43,14 +43,12 @@ export default async function WorkspaceLayout({
|
|||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</Link>
|
</Link>
|
||||||
<form action="/api/v1/auth/logout" method="post">
|
<Link
|
||||||
<button
|
href="/api/v1/auth/logout"
|
||||||
type="submit"
|
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
|
||||||
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
|
>
|
||||||
>
|
Logout & Reset
|
||||||
Logout & Reset
|
</Link>
|
||||||
</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, url } = useArtifactContent({
|
const { content } = useArtifactContent({
|
||||||
threadId,
|
threadId,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
enabled: isCodeFile && !isWriteFile,
|
enabled: isCodeFile && !isWriteFile,
|
||||||
@@ -254,9 +254,7 @@ 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" && (
|
||||||
@@ -279,33 +277,27 @@ 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" || isWriteFile) {
|
if (language !== "html") {
|
||||||
setHtmlPreviewUrl(undefined);
|
setHtmlPreviewUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([htmlWithBaseHref(content ?? "", url)], {
|
const blob = new Blob([content ?? ""], { type: "text/html" });
|
||||||
type: "text/html",
|
const url = URL.createObjectURL(blob);
|
||||||
});
|
setHtmlPreviewUrl(url);
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
setHtmlPreviewUrl(objectUrl);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
}, [content, isWriteFile, language, url]);
|
}, [content, language]);
|
||||||
|
|
||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
@@ -326,35 +318,9 @@ 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={isWriteFile ? undefined : htmlPreviewUrl}
|
src={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("&", "&").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
## 文档结构
|
## 文档结构
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,6 @@
|
|||||||
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";
|
||||||
@@ -39,10 +32,6 @@ 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({
|
||||||
@@ -69,44 +58,6 @@ 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";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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({
|
||||||
@@ -13,9 +12,6 @@ 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" : ""}`;
|
||||||
}
|
}
|
||||||
@@ -27,21 +23,5 @@ 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" : ""}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ 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
|
||||||
@@ -48,7 +46,6 @@ 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;
|
||||||
|
|
||||||
@@ -57,8 +54,6 @@ 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", {
|
||||||
@@ -82,7 +77,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [staticMode, pathname, router]);
|
}, [pathname, router]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout - call FastAPI logout endpoint and clear local state
|
* Logout - call FastAPI logout endpoint and clear local state
|
||||||
@@ -92,11 +87,6 @@ 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",
|
||||||
@@ -109,7 +99,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
|
|||||||
|
|
||||||
// Redirect to home page
|
// Redirect to home page
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}, [staticMode, router]);
|
}, [router]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle visibility change - refresh user when tab becomes visible again.
|
* Handle visibility change - refresh user when tab becomes visible again.
|
||||||
@@ -118,8 +108,6 @@ 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();
|
||||||
@@ -132,7 +120,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [staticMode, user, refreshUser]);
|
}, [user, refreshUser]);
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user,
|
user,
|
||||||
@@ -167,8 +155,6 @@ 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"));
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
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;
|
||||||
@@ -13,13 +10,6 @@ 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",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { env } from "@/env";
|
|
||||||
|
|
||||||
export function isStaticWebsiteOnly() {
|
|
||||||
return env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user