mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 00:16:48 +00:00
feat(auth): authentication module with multi-tenant isolation (RFC-001)
Introduce an always-on auth layer with auto-created admin on first boot, multi-tenant isolation for threads/stores, and a full setup/login flow. Backend - JWT access tokens with `ver` field for stale-token rejection; bump on password/email change - Password hashing, HttpOnly+Secure cookies (Secure derived from request scheme at runtime) - CSRF middleware covering both REST and LangGraph routes - IP-based login rate limiting (5 attempts / 5-min lockout) with bounded dict growth and X-Forwarded-For bypass fix - Multi-worker-safe admin auto-creation (single DB write, WAL once) - needs_setup + token_version on User model; SQLite schema migration - Thread/store isolation by owner; orphan thread migration on first admin registration - thread_id validated as UUID to prevent log injection - CLI tool to reset admin password - Decorator-based authz module extracted from auth core Frontend - Login and setup pages with SSR guard for needs_setup flow - Account settings page (change password / email) - AuthProvider + route guards; skips redirect when no users registered - i18n (en-US / zh-CN) for auth surfaces - Typed auth API client; parseAuthError unwraps FastAPI detail envelope Infra & tooling - Unified `serve.sh` with gateway mode + auto dep install - Public PyPI uv.toml pin for CI compatibility - Regenerated uv.lock with public index Tests - HTTP vs HTTPS cookie security tests - Auth middleware, rate limiter, CSRF, setup flow coverage
This commit is contained in:
+45
-13
@@ -10,12 +10,24 @@ function getInternalServiceURL(envKey, fallbackURL) {
|
||||
? configured.replace(/\/+$/, "")
|
||||
: fallbackURL;
|
||||
}
|
||||
import nextra from "nextra";
|
||||
|
||||
const withNextra = nextra({});
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
i18n: {
|
||||
locales: ["en", "zh"],
|
||||
defaultLocale: "en",
|
||||
},
|
||||
devIndicators: false,
|
||||
allowedDevOrigins: process.env.NEXT_DEV_ALLOWED_ORIGINS
|
||||
? process.env.NEXT_DEV_ALLOWED_ORIGINS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
async rewrites() {
|
||||
const rewrites = [];
|
||||
const beforeFiles = [];
|
||||
const langgraphURL = getInternalServiceURL(
|
||||
"DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URL",
|
||||
"http://127.0.0.1:2024",
|
||||
@@ -26,29 +38,49 @@ const config = {
|
||||
);
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
|
||||
rewrites.push({
|
||||
beforeFiles.push({
|
||||
source: "/api/langgraph",
|
||||
destination: langgraphURL,
|
||||
});
|
||||
rewrites.push({
|
||||
beforeFiles.push({
|
||||
source: "/api/langgraph/:path*",
|
||||
destination: `${langgraphURL}/:path*`,
|
||||
});
|
||||
}
|
||||
|
||||
// Auth endpoints: explicit v1/auth prefix only (deny-by-default)
|
||||
beforeFiles.push({
|
||||
source: "/api/v1/auth/:path*",
|
||||
destination: `${gatewayURL}/api/v1/auth/:path*`,
|
||||
});
|
||||
|
||||
// LangGraph-compat: handled by route handler at /api/langgraph-compat/[...path]
|
||||
// with allowlist, header sanitization, and timeout — no rewrite needed.
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
rewrites.push({
|
||||
source: "/api/agents",
|
||||
destination: `${gatewayURL}/api/agents`,
|
||||
});
|
||||
rewrites.push({
|
||||
source: "/api/agents/:path*",
|
||||
destination: `${gatewayURL}/api/agents/:path*`,
|
||||
});
|
||||
// Explicit gateway API prefixes (deny-by-default, no catch-all)
|
||||
const GATEWAY_PREFIXES = [
|
||||
"agents",
|
||||
"models",
|
||||
"threads",
|
||||
"memory",
|
||||
"skills",
|
||||
"mcp",
|
||||
];
|
||||
for (const prefix of GATEWAY_PREFIXES) {
|
||||
beforeFiles.push({
|
||||
source: `/api/${prefix}`,
|
||||
destination: `${gatewayURL}/api/${prefix}`,
|
||||
});
|
||||
beforeFiles.push({
|
||||
source: `/api/${prefix}/:path*`,
|
||||
destination: `${gatewayURL}/api/${prefix}/:path*`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rewrites;
|
||||
return { beforeFiles, afterFiles: [], fallback: [] };
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default withNextra(config);
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextra": "^4.6.1",
|
||||
"nextra-theme-docs": "^4.6.1",
|
||||
"nuxt-og-image": "^5.1.13",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
|
||||
Generated
+1363
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { getServerSideUser } from "@/core/auth/server";
|
||||
import { assertNever } from "@/core/auth/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const result = await getServerSideUser();
|
||||
|
||||
switch (result.tag) {
|
||||
case "authenticated":
|
||||
redirect("/workspace");
|
||||
case "needs_setup":
|
||||
// Allow access to setup page
|
||||
return <AuthProvider initialUser={result.user}>{children}</AuthProvider>;
|
||||
case "unauthenticated":
|
||||
return <AuthProvider initialUser={null}>{children}</AuthProvider>;
|
||||
case "gateway_unavailable":
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<p className="text-muted-foreground">
|
||||
Service temporarily unavailable.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
Retry
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
case "config_error":
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
/**
|
||||
* Validate next parameter
|
||||
* Prevent open redirect attacks
|
||||
* Per RFC-001: Only allow relative paths starting with /
|
||||
*/
|
||||
function validateNextParam(next: string | null): string | null {
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need start with / (relative path)
|
||||
if (!next.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Disallow protocol-relative URLs
|
||||
if (
|
||||
next.startsWith("//") ||
|
||||
next.startsWith("http://") ||
|
||||
next.startsWith("https://")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Disallow URLs with different protocols (e.g., javascript:, data:, etc)
|
||||
if (next.includes(":") && !next.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Valid relative path
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get next parameter for validated redirect
|
||||
const nextParam = searchParams.get("next");
|
||||
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
|
||||
|
||||
// Redirect if already authenticated (client-side, post-login)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
}, [isAuthenticated, redirectPath, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = isLogin
|
||||
? "/api/v1/auth/login/local"
|
||||
: "/api/v1/auth/register";
|
||||
const body = isLogin
|
||||
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
|
||||
: JSON.stringify({ email, password });
|
||||
|
||||
const headers: HeadersInit = isLogin
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: { "Content-Type": "application/json" };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
credentials: "include", // Important: include HttpOnly cookie
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both login and register set a cookie — redirect to workspace
|
||||
router.push(redirectPath);
|
||||
} catch (_err) {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="border-border/20 w-full max-w-md space-y-6 rounded-lg border bg-black/50 p-8 backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h1 className="font-serif text-3xl">DeerFlow</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{isLogin ? "Sign in to your account" : "Create a new account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="mt-1 bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="•••••••"
|
||||
required
|
||||
minLength={isLogin ? 6 : 8}
|
||||
className="mt-1 bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading
|
||||
? "Please wait..."
|
||||
: isLogin
|
||||
? "Sign In"
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setError("");
|
||||
}}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{isLogin
|
||||
? "Don't have an account? Sign up"
|
||||
: "Already have an account? Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
<Link href="/" className="hover:underline">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
new_email: email || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/workspace");
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6 p-6">
|
||||
<div className="text-center">
|
||||
<h1 className="font-serif text-3xl">DeerFlow</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Complete admin account setup
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Set your real email and a new password.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSetup} className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current password (from console log)"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Complete Setup"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { generateStaticParamsFor, importPage } from "nextra/pages";
|
||||
|
||||
import { useMDXComponents as getMDXComponents } from "../../../../mdx-components";
|
||||
|
||||
export const generateStaticParams = generateStaticParamsFor("mdxPath");
|
||||
|
||||
export async function generateMetadata(props) {
|
||||
const params = await props.params;
|
||||
const { metadata } = await importPage(params.mdxPath, params.lang);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const Wrapper = getMDXComponents().wrapper;
|
||||
|
||||
export default async function Page(props) {
|
||||
const params = await props.params;
|
||||
const {
|
||||
default: MDXContent,
|
||||
toc,
|
||||
metadata,
|
||||
sourceCode,
|
||||
} = await importPage(params.mdxPath, params.lang);
|
||||
return (
|
||||
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
|
||||
<MDXContent {...props} params={params} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PageMapItem } from "nextra";
|
||||
import { getPageMap } from "nextra/page-map";
|
||||
import { Footer, Layout } from "nextra-theme-docs";
|
||||
|
||||
import { Header } from "@/components/landing/header";
|
||||
import { getLocaleByLang } from "@/core/i18n/locale";
|
||||
import "nextra-theme-docs/style.css";
|
||||
|
||||
const footer = <Footer>MIT {new Date().getFullYear()} © Nextra.</Footer>;
|
||||
|
||||
const i18n = [
|
||||
{ locale: "en", name: "English" },
|
||||
{ locale: "zh", name: "中文" },
|
||||
];
|
||||
|
||||
function formatPageRoute(base: string, items: PageMapItem[]): PageMapItem[] {
|
||||
return items.map((item) => {
|
||||
if ("route" in item) {
|
||||
item.route = `${base}${item.route}`;
|
||||
}
|
||||
if ("children" in item && item.children) {
|
||||
item.children = formatPageRoute(base, item.children);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export default async function DocLayout({ children, params }) {
|
||||
const { lang } = await params;
|
||||
const locale = getLocaleByLang(lang);
|
||||
const pages = await getPageMap(`/${lang}`);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
navbar={
|
||||
<Header
|
||||
className="relative max-w-full px-10"
|
||||
homeURL="/"
|
||||
locale={locale}
|
||||
/>
|
||||
}
|
||||
pageMap={formatPageRoute(`/${lang}/docs`, pages)}
|
||||
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/app/content"
|
||||
footer={footer}
|
||||
i18n={i18n}
|
||||
// ... Your additional layout options
|
||||
>
|
||||
{children}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
import { auth } from "@/server/better-auth";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,7 +11,11 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
@@ -28,6 +32,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_name } = useParams<{
|
||||
@@ -81,6 +86,11 @@ export default function AgentChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
@@ -128,6 +138,7 @@ export default function AgentChatPage() {
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -173,6 +184,7 @@ export default function AgentChatPage() {
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
CheckCircleIcon,
|
||||
InfoIcon,
|
||||
MoreHorizontalIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
@@ -10,17 +18,20 @@ import {
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
AgentNameCheckError,
|
||||
checkAgentName,
|
||||
getAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { checkAgentName, getAgent } from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -28,23 +39,46 @@ import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "name" | "chat";
|
||||
type SetupAgentStatus = "idle" | "requested" | "completed";
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9-]+$/;
|
||||
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getAgentWithRetry(agentName: string) {
|
||||
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||
if (delay > 0) {
|
||||
await wait(delay);
|
||||
}
|
||||
|
||||
try {
|
||||
return await getAgent(agentName);
|
||||
} catch {
|
||||
// Retry until the write settles or the attempts are exhausted.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
const [step, setStep] = useState<Step>("name");
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
const [showSaveHint, setShowSaveHint] = useState(false);
|
||||
const [setupAgentStatus, setSetupAgentStatus] =
|
||||
useState<SetupAgentStatus>("idle");
|
||||
|
||||
// Stable thread ID — all turns belong to the same thread
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
@@ -53,17 +87,35 @@ export default function NewAgentPage() {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
},
|
||||
onFinish() {
|
||||
if (!agent && setupAgentStatus === "requested") {
|
||||
setSetupAgentStatus("idle");
|
||||
}
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentName) return;
|
||||
getAgent(agentName)
|
||||
.then((fetched) => setAgent(fetched))
|
||||
.catch(() => {
|
||||
// agent write may not be flushed yet — ignore silently
|
||||
});
|
||||
setSetupAgentStatus("completed");
|
||||
void getAgentWithRetry(agentName).then((fetched) => {
|
||||
if (fetched) {
|
||||
setAgent(fetched);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t.agents.agentCreatedPendingRefresh);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || step !== "chat") {
|
||||
return;
|
||||
}
|
||||
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
|
||||
return;
|
||||
}
|
||||
setShowSaveHint(true);
|
||||
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
|
||||
}, [step]);
|
||||
|
||||
const handleConfirmName = useCallback(async () => {
|
||||
const trimmed = nameInput.trim();
|
||||
@@ -72,6 +124,7 @@ export default function NewAgentPage() {
|
||||
setNameError(t.agents.nameStepInvalidError);
|
||||
return;
|
||||
}
|
||||
|
||||
setNameError("");
|
||||
setIsCheckingName(true);
|
||||
try {
|
||||
@@ -90,6 +143,7 @@ export default function NewAgentPage() {
|
||||
} finally {
|
||||
setIsCheckingName(false);
|
||||
}
|
||||
|
||||
setAgentName(trimmed);
|
||||
setStep("chat");
|
||||
await sendMessage(threadId, {
|
||||
@@ -99,12 +153,12 @@ export default function NewAgentPage() {
|
||||
}, [
|
||||
nameInput,
|
||||
sendMessage,
|
||||
threadId,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepInvalidError,
|
||||
t.agents.nameStepAlreadyExistsError,
|
||||
t.agents.nameStepNetworkError,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepCheckError,
|
||||
t.agents.nameStepInvalidError,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -124,26 +178,82 @@ export default function NewAgentPage() {
|
||||
{ agent_name: agentName },
|
||||
);
|
||||
},
|
||||
[thread.isLoading, sendMessage, threadId, agentName],
|
||||
[agentName, sendMessage, thread.isLoading, threadId],
|
||||
);
|
||||
|
||||
// ── Shared header ──────────────────────────────────────────────────────────
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
if (
|
||||
!agentName ||
|
||||
agent ||
|
||||
thread.isLoading ||
|
||||
setupAgentStatus !== "idle"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupAgentStatus("requested");
|
||||
setShowSaveHint(false);
|
||||
try {
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: t.agents.saveCommandMessage, files: [] },
|
||||
{ agent_name: agentName },
|
||||
{ additionalKwargs: { hide_from_ui: true } },
|
||||
);
|
||||
toast.success(t.agents.saveRequested);
|
||||
} catch (error) {
|
||||
setSetupAgentStatus("idle");
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentName,
|
||||
sendMessage,
|
||||
setupAgentStatus,
|
||||
t.agents.saveCommandMessage,
|
||||
t.agents.saveRequested,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const header = (
|
||||
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
||||
</div>
|
||||
|
||||
{step === "chat" ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleSaveAgent()}
|
||||
disabled={
|
||||
!!agent || thread.isLoading || setupAgentStatus !== "idle"
|
||||
}
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{setupAgentStatus === "requested"
|
||||
? t.agents.saving
|
||||
: t.agents.save}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
|
||||
if (step === "name") {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
@@ -176,9 +286,9 @@ export default function NewAgentPage() {
|
||||
onKeyDown={handleNameKeyDown}
|
||||
className={cn(nameError && "border-destructive")}
|
||||
/>
|
||||
{nameError && (
|
||||
{nameError ? (
|
||||
<p className="text-destructive text-sm">{nameError}</p>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleConfirmName()}
|
||||
@@ -193,8 +303,6 @@ export default function NewAgentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
@@ -202,20 +310,28 @@ export default function NewAgentPage() {
|
||||
{header}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{/* ── Message area ── */}
|
||||
{showSaveHint ? (
|
||||
<div className="px-4 pt-4">
|
||||
<div className="mx-auto w-full max-w-(--container-width-md)">
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>{t.agents.saveHint}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className="size-full pt-10"
|
||||
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom action area ── */}
|
||||
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{agent ? (
|
||||
// ✅ Success card
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
|
||||
<CheckCircleIcon className="text-primary h-10 w-10" />
|
||||
<p className="font-semibold">{t.agents.agentCreated}</p>
|
||||
@@ -238,7 +354,6 @@ export default function NewAgentPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 📝 Normal input
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
@@ -27,10 +31,16 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -70,6 +80,11 @@ export default function ChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
@@ -97,6 +112,7 @@ export default function ChatPage() {
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
@@ -120,30 +136,42 @@ export default function ChatPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
||||
@@ -1,47 +1,58 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { getServerSideUser } from "@/core/auth/server";
|
||||
import { assertNever } from "@/core/auth/types";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||
import { WorkspaceContent } from "./workspace-content";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function WorkspaceLayout({
|
||||
export default async function WorkspaceLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
|
||||
useLayoutEffect(() => {
|
||||
// Runs synchronously before first paint on the client — no visual flash
|
||||
setOpen(!getLocalSettings().layout.sidebar_collapsed);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setOpen(!settings.layout.sidebar_collapsed);
|
||||
}, [settings.layout.sidebar_collapsed]);
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
setSettings("layout", { sidebar_collapsed: !open });
|
||||
},
|
||||
[setSettings],
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider
|
||||
className="h-screen"
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = await getServerSideUser();
|
||||
|
||||
switch (result.tag) {
|
||||
case "authenticated":
|
||||
return (
|
||||
<AuthProvider initialUser={result.user}>
|
||||
<WorkspaceContent>{children}</WorkspaceContent>
|
||||
</AuthProvider>
|
||||
);
|
||||
case "needs_setup":
|
||||
redirect("/setup");
|
||||
case "unauthenticated":
|
||||
redirect("/login");
|
||||
case "gateway_unavailable":
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<p className="text-muted-foreground">
|
||||
Service temporarily unavailable.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The backend may be restarting. Please wait a moment and try again.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
Retry
|
||||
</Link>
|
||||
<Link
|
||||
href="/api/v1/auth/logout"
|
||||
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
|
||||
>
|
||||
Logout & Reset
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "config_error":
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||
|
||||
export function WorkspaceContent({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Runs synchronously before first paint on the client — no visual flash
|
||||
setOpen(!getLocalSettings().layout.sidebar_collapsed);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(!settings.layout.sidebar_collapsed);
|
||||
}, [settings.layout.sidebar_collapsed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
setSettings("layout", { sidebar_collapsed: !open });
|
||||
},
|
||||
[setSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider
|
||||
className="h-screen"
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PromptInputFilePart } from "@/core/uploads";
|
||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus, FileUIPart } from "ai";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
ImageIcon,
|
||||
@@ -71,13 +73,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ============================================================================
|
||||
// Provider Context & Types
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentsContext = {
|
||||
files: (FileUIPart & { id: string })[];
|
||||
files: (PromptInputFilePart & { id: string })[];
|
||||
add: (files: File[] | FileList) => void;
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
@@ -107,6 +110,9 @@ const PromptInputController = createContext<PromptInputControllerProps | null>(
|
||||
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
||||
null,
|
||||
);
|
||||
const PromptInputValidationContext = createContext<
|
||||
((files: File[] | FileList) => File[]) | null
|
||||
>(null);
|
||||
|
||||
export const usePromptInputController = () => {
|
||||
const ctx = useContext(PromptInputController);
|
||||
@@ -134,6 +140,7 @@ export const useProviderAttachments = () => {
|
||||
|
||||
const useOptionalProviderAttachments = () =>
|
||||
useContext(ProviderAttachmentsContext);
|
||||
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
@@ -153,7 +160,7 @@ export function PromptInputProvider({
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(FileUIPart & { id: string })[]
|
||||
(PromptInputFilePart & { id: string })[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
@@ -172,6 +179,7 @@ export function PromptInputProvider({
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -279,7 +287,7 @@ export const usePromptInputAttachments = () => {
|
||||
};
|
||||
|
||||
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart & { id: string };
|
||||
data: PromptInputFilePart & { id: string };
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -378,7 +386,7 @@ export type PromptInputAttachmentsProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children"
|
||||
> & {
|
||||
children: (attachment: FileUIPart & { id: string }) => ReactNode;
|
||||
children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
|
||||
};
|
||||
|
||||
export function PromptInputAttachments({
|
||||
@@ -433,7 +441,7 @@ export const PromptInputActionAddAttachments = ({
|
||||
|
||||
export type PromptInputMessage = {
|
||||
text: string;
|
||||
files: FileUIPart[];
|
||||
files: PromptInputFilePart[];
|
||||
};
|
||||
|
||||
export type PromptInputProps = Omit<
|
||||
@@ -451,7 +459,7 @@ export type PromptInputProps = Omit<
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept";
|
||||
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
|
||||
message: string;
|
||||
}) => void;
|
||||
onSubmit: (
|
||||
@@ -483,7 +491,9 @@ export const PromptInput = ({
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
// ----- Local attachments (only used when no provider)
|
||||
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
||||
const [items, setItems] = useState<(PromptInputFilePart & { id: string })[]>(
|
||||
[],
|
||||
);
|
||||
const files = usingProvider ? controller.attachments.files : items;
|
||||
|
||||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
||||
@@ -551,7 +561,7 @@ export const PromptInput = ({
|
||||
message: "Too many files. Some were not added.",
|
||||
});
|
||||
}
|
||||
const next: (FileUIPart & { id: string })[] = [];
|
||||
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||
for (const file of capped) {
|
||||
next.push({
|
||||
id: nanoid(),
|
||||
@@ -559,6 +569,7 @@ export const PromptInput = ({
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return prev.concat(next);
|
||||
@@ -599,6 +610,23 @@ export const PromptInput = ({
|
||||
? controller.attachments.openFileDialog
|
||||
: openFileDialogLocal;
|
||||
|
||||
const sanitizeIncomingFiles = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const { accepted, message } = splitUnsupportedUploadFiles(fileList);
|
||||
if (message) {
|
||||
onError?.({
|
||||
code: "unsupported_package",
|
||||
message,
|
||||
});
|
||||
if (!onError) {
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
||||
useEffect(() => {
|
||||
if (!usingProvider) return;
|
||||
@@ -629,7 +657,10 @@ export const PromptInput = ({
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
add(e.dataTransfer.files);
|
||||
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
|
||||
if (accepted.length > 0) {
|
||||
add(accepted);
|
||||
}
|
||||
}
|
||||
};
|
||||
form.addEventListener("dragover", onDragOver);
|
||||
@@ -638,7 +669,7 @@ export const PromptInput = ({
|
||||
form.removeEventListener("dragover", onDragOver);
|
||||
form.removeEventListener("drop", onDrop);
|
||||
};
|
||||
}, [add, globalDrop]);
|
||||
}, [add, globalDrop, sanitizeIncomingFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalDrop) return;
|
||||
@@ -653,7 +684,10 @@ export const PromptInput = ({
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
add(e.dataTransfer.files);
|
||||
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
|
||||
if (accepted.length > 0) {
|
||||
add(accepted);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("dragover", onDragOver);
|
||||
@@ -662,7 +696,7 @@ export const PromptInput = ({
|
||||
document.removeEventListener("dragover", onDragOver);
|
||||
document.removeEventListener("drop", onDrop);
|
||||
};
|
||||
}, [add, globalDrop]);
|
||||
}, [add, globalDrop, sanitizeIncomingFiles]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -678,7 +712,10 @@ export const PromptInput = ({
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
if (event.currentTarget.files) {
|
||||
add(event.currentTarget.files);
|
||||
const accepted = sanitizeIncomingFiles(event.currentTarget.files);
|
||||
if (accepted.length > 0) {
|
||||
add(accepted);
|
||||
}
|
||||
}
|
||||
// Reset input value to allow selecting files that were previously removed
|
||||
event.currentTarget.value = "";
|
||||
@@ -733,6 +770,10 @@ export const PromptInput = ({
|
||||
// Convert blob URLs to data URLs asynchronously
|
||||
Promise.all(
|
||||
files.map(async ({ id, ...item }) => {
|
||||
if (item.file instanceof File) {
|
||||
// Downstream upload prep reads the preserved File directly.
|
||||
return item;
|
||||
}
|
||||
if (item.url && item.url.startsWith("blob:")) {
|
||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||
// If conversion failed, keep the original blob URL
|
||||
@@ -744,7 +785,7 @@ export const PromptInput = ({
|
||||
return item;
|
||||
}),
|
||||
)
|
||||
.then((convertedFiles: FileUIPart[]) => {
|
||||
.then((convertedFiles: PromptInputFilePart[]) => {
|
||||
try {
|
||||
const result = onSubmit({ text, files: convertedFiles }, event);
|
||||
|
||||
@@ -778,7 +819,7 @@ export const PromptInput = ({
|
||||
|
||||
// Render with or without local provider
|
||||
const inner = (
|
||||
<>
|
||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||
<input
|
||||
accept={accept}
|
||||
aria-label="Upload files"
|
||||
@@ -797,7 +838,7 @@ export const PromptInput = ({
|
||||
>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
</form>
|
||||
</>
|
||||
</PromptInputValidationContext.Provider>
|
||||
);
|
||||
|
||||
return usingProvider ? (
|
||||
@@ -830,6 +871,7 @@ export const PromptInputTextarea = ({
|
||||
}: PromptInputTextareaProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
@@ -888,7 +930,12 @@ export const PromptInputTextarea = ({
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
attachments.add(files);
|
||||
const accepted = sanitizeIncomingFiles
|
||||
? sanitizeIncomingFiles(files)
|
||||
: files;
|
||||
if (accepted.length > 0) {
|
||||
attachments.add(accepted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import type { Locale } from "@/core/i18n/locale";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Header() {
|
||||
export type HeaderProps = {
|
||||
className?: string;
|
||||
homeURL?: string;
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
const isExternalHome = !homeURL;
|
||||
const { locale: resolvedLocale, t } = await getI18n(locale);
|
||||
const lang = resolvedLocale.substring(0, 2);
|
||||
return (
|
||||
<header className="container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<header
|
||||
className={cn(
|
||||
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<h1 className="font-serif text-xl">DeerFlow</h1>
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||
<Link
|
||||
href={`/${lang}/docs`}
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.docs}
|
||||
</Link>
|
||||
<a
|
||||
href={`/${lang}/blog`}
|
||||
target="_self"
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.blog}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
|
||||
|
||||
@@ -52,8 +52,8 @@ function Button({
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -35,6 +35,7 @@ export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/workspace/chats/new");
|
||||
@@ -63,8 +64,9 @@ export function CommandPalette() {
|
||||
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
const metaKey = isMac ? "⌘" : "Ctrl+";
|
||||
const shiftKey = isMac ? "⇧" : "Shift+";
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ export function InputBox({
|
||||
threadId,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
...props
|
||||
@@ -136,6 +137,7 @@ export function InputBox({
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
@@ -186,6 +188,8 @@ export function InputBox({
|
||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
||||
}, [context.model_name, models]);
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
@@ -253,9 +257,33 @@ export function InputBox({
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.(message);
|
||||
},
|
||||
[onSubmit, onStop, status],
|
||||
[
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
@@ -309,6 +337,26 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
|
||||
}, [onFollowupsVisibilityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current?.(showFollowups);
|
||||
}, [showFollowups]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => followupsVisibilityChangeRef.current?.(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const streaming = status === "streaming";
|
||||
const wasStreaming = wasStreamingRef.current;
|
||||
@@ -769,40 +817,37 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInput>
|
||||
|
||||
{!disabled &&
|
||||
!isNewThread &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0) && (
|
||||
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{followupsLoading ? (
|
||||
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
|
||||
{t.inputBox.followupLoading}
|
||||
</div>
|
||||
) : (
|
||||
<Suggestions className="min-h-16 w-fit items-start">
|
||||
{followups.map((s) => (
|
||||
<Suggestion
|
||||
key={s}
|
||||
suggestion={s}
|
||||
onClick={() => handleFollowupClick(s)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setFollowupsHidden(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</Suggestions>
|
||||
)}
|
||||
</div>
|
||||
{showFollowups && (
|
||||
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{followupsLoading ? (
|
||||
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
|
||||
{t.inputBox.followupLoading}
|
||||
</div>
|
||||
) : (
|
||||
<Suggestions className="min-h-16 w-fit items-start">
|
||||
{followups.map((s) => (
|
||||
<Suggestion
|
||||
key={s}
|
||||
suggestion={s}
|
||||
onClick={() => handleFollowupClick(s)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setFollowupsHidden(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</Suggestions>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -29,11 +29,14 @@ import { MessageListItem } from "./message-list-item";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
|
||||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
paddingBottom = 160,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchWithAuth("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title="Profile">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Email</span>
|
||||
<span className="text-sm font-medium">{user?.email ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Role</span>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{user?.system_role ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Change Password">
|
||||
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{message && <p className="text-sm text-green-500">{message}</p>}
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Session">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
@@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
@@ -54,6 +57,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "account",
|
||||
label: t.settings.sections.account,
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: t.settings.sections.appearance,
|
||||
@@ -74,6 +82,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.account,
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
@@ -124,6 +133,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
</nav>
|
||||
<ScrollArea className="h-full min-h-0 rounded-lg border">
|
||||
<div className="space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
ChevronsUpDown,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
MailIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { GithubIcon } from "./github-icon";
|
||||
@@ -32,20 +34,22 @@ import { SettingsDialog } from "./settings";
|
||||
|
||||
function NavMenuButtonContent({
|
||||
isSidebarOpen,
|
||||
email,
|
||||
t,
|
||||
}: {
|
||||
isSidebarOpen: boolean;
|
||||
email?: string;
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
}) {
|
||||
return isSidebarOpen ? (
|
||||
<div className="text-muted-foreground flex w-full items-center gap-2 text-left text-sm">
|
||||
<SettingsIcon className="size-4" />
|
||||
<span>{t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto size-4" />
|
||||
<UserIcon className="size-4 shrink-0" />
|
||||
<span className="truncate">{email ?? t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto size-4 shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<SettingsIcon className="text-muted-foreground size-4" />
|
||||
<UserIcon className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,11 +57,18 @@ function NavMenuButtonContent({
|
||||
export function WorkspaceNavMenu() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about"
|
||||
>("appearance");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -79,7 +90,11 @@ export function WorkspaceNavMenu() {
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
<NavMenuButtonContent
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
email={user?.email}
|
||||
t={t}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -87,6 +102,14 @@ export function WorkspaceNavMenu() {
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{user?.email && (
|
||||
<>
|
||||
<div className="text-muted-foreground truncate px-2 py-1.5 text-xs">
|
||||
{user.email}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -146,11 +169,24 @@ export function WorkspaceNavMenu() {
|
||||
<InfoIcon />
|
||||
{t.workspace.about}
|
||||
</DropdownMenuItem>
|
||||
{user && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={logout}>
|
||||
<LogOutIcon />
|
||||
{t.workspace.logout}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
<NavMenuButtonContent
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
email={user?.email}
|
||||
t={t}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
index: {
|
||||
title: "Overview",
|
||||
},
|
||||
introduction: {
|
||||
title: "Introduction",
|
||||
},
|
||||
harness: {
|
||||
title: "DeerFlow Harness",
|
||||
},
|
||||
application: {
|
||||
title: "DeerFlow App",
|
||||
},
|
||||
tutorials: {
|
||||
title: "Tutorials",
|
||||
},
|
||||
reference: {
|
||||
title: "Reference",
|
||||
},
|
||||
workspace: {
|
||||
type: "page",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
index: {
|
||||
title: "Overview",
|
||||
},
|
||||
"quick-start": {
|
||||
title: "Quick Start",
|
||||
},
|
||||
"deployment-guide": {
|
||||
title: "Deployment Guide",
|
||||
},
|
||||
configuration: {
|
||||
title: "Configuration",
|
||||
},
|
||||
"workspace-usage": {
|
||||
title: "Workspace Usage",
|
||||
},
|
||||
"agents-and-threads": {
|
||||
title: "Agents and Threads",
|
||||
},
|
||||
"operations-and-troubleshooting": {
|
||||
title: "Operations and Troubleshooting",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Agents and Threads
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Deployment Guide
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# DeerFlow App
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Operations and Troubleshooting
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Quick Start
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Workspace Usage
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
index: {
|
||||
title: "Install",
|
||||
},
|
||||
"quick-start": {
|
||||
title: "Quick Start",
|
||||
},
|
||||
"design-principles": {
|
||||
title: "Design Principles",
|
||||
},
|
||||
configuration: {
|
||||
title: "Configuration",
|
||||
},
|
||||
memory: {
|
||||
title: "Memory",
|
||||
},
|
||||
tools: {
|
||||
title: "Tools",
|
||||
},
|
||||
skills: {
|
||||
title: "Skills",
|
||||
},
|
||||
sandbox: {
|
||||
title: "Sandbox",
|
||||
},
|
||||
customization: {
|
||||
title: "Customization",
|
||||
},
|
||||
"integration-guide": {
|
||||
title: "Integration Guide",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Customization
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Design Principles
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Callout, Cards } from "nextra/components";
|
||||
|
||||
# Install DeerFlow Harness
|
||||
|
||||
<Callout type="info" emoji="📦">
|
||||
The DeerFlow Harness Python package will be published as <code>deerflow</code>
|
||||
. It is not released yet, so installation is currently{" "}
|
||||
<strong>Coming Soon</strong>.
|
||||
</Callout>
|
||||
|
||||
The DeerFlow Harness is the Python SDK and runtime foundation for building your own Super Agent systems.
|
||||
|
||||
If you want to compose agents with skills, memory, tools, sandboxes, and subagents inside your own product or workflow, this is the part of DeerFlow you will build on.
|
||||
|
||||
## Package name
|
||||
|
||||
The package name will be:
|
||||
|
||||
```bash
|
||||
pip install deerflow
|
||||
```
|
||||
|
||||
That package is not publicly available yet, but this is the installation path the documentation will use once it is released.
|
||||
|
||||
## Current status
|
||||
|
||||
The DeerFlow Harness package is **coming soon**.
|
||||
|
||||
At the moment, this section exists to establish the SDK entry point and package identity, while the public distribution flow is being finalized.
|
||||
|
||||
## What the Harness will give you
|
||||
|
||||
The Harness is designed for developers who want to build their own agent system on top of DeerFlow's runtime model.
|
||||
|
||||
It will provide the foundation for:
|
||||
|
||||
- building long-horizon agents,
|
||||
- composing runtime capabilities such as memory, tools, skills, and subagents,
|
||||
- running agents with sandboxed execution,
|
||||
- customizing agent behavior through configuration and code, and
|
||||
- integrating DeerFlow into your own application architecture.
|
||||
|
||||
## What to do next
|
||||
|
||||
Until the package is released, the best way to understand the DeerFlow Harness is to read the conceptual and implementation docs in this section.
|
||||
|
||||
<Cards num={2}>
|
||||
<Cards.Card title="Quick Start" href="/docs/harness/quick-start" />
|
||||
<Cards.Card title="Configuration" href="/docs/harness/configuration" />
|
||||
</Cards>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Integration Guide
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Memory
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Quick Start
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Sandbox
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Skills
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tools
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: DeerFlow Documentation
|
||||
description: Understand DeerFlow, build with the Harness, and deploy the App.
|
||||
---
|
||||
|
||||
# DeerFlow Documentation
|
||||
|
||||
DeerFlow is a framework for building and operating agent systems. It gives you a runtime harness for composing agents with memory, tools, skills, sandboxes, and subagents, and it also provides an application layer that turns those capabilities into a usable product experience.
|
||||
|
||||
This documentation is organized around those two parts:
|
||||
|
||||
- **DeerFlow Harness**: the core SDK and runtime layer for building your own agent system.
|
||||
- **DeerFlow App**: a reference application built on top of the Harness for deployment, operations, and end-user workflows.
|
||||
|
||||
If you want to understand how DeerFlow works, start with the Introduction. If you want to build on the core runtime, go to the Harness docs. If you want to deploy and use DeerFlow as an application, go to the App docs.
|
||||
|
||||
## Start here
|
||||
|
||||
### If you are new to DeerFlow
|
||||
|
||||
Start with the conceptual overview first.
|
||||
|
||||
- [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)
|
||||
|
||||
### 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)
|
||||
|
||||
## Documentation structure
|
||||
|
||||
### Introduction
|
||||
|
||||
The Introduction section helps you build the right mental model before you look at implementation details.
|
||||
|
||||
- **What is DeerFlow** explains what DeerFlow is and what problems it is designed to solve.
|
||||
- **Why DeerFlow** explains the motivation behind the project.
|
||||
- **Core Concepts** introduces the concepts that appear across the documentation.
|
||||
- **Harness vs App** explains the relationship between the runtime layer and the application layer.
|
||||
|
||||
### DeerFlow Harness
|
||||
|
||||
The Harness section is the core of the technical documentation. It is written for developers who want to build their own DeerFlow-based system.
|
||||
|
||||
- **Quick Start** shows how to create your first harness with the core DeerFlow API.
|
||||
- **Design Principles** explains the architectural ideas behind the Harness.
|
||||
- **Configuration** covers the main configuration surface of the SDK/runtime.
|
||||
- **Memory**, **Tools**, **Skills**, and **Sandbox** explain the major system capabilities separately.
|
||||
- **Customization** shows how to adapt DeerFlow to your own product requirements.
|
||||
- **Integration Guide** explains how to integrate the Harness into a larger system.
|
||||
|
||||
### DeerFlow App
|
||||
|
||||
The App section is written for teams who want to deploy DeerFlow as a usable product.
|
||||
|
||||
- **Quick Start** helps you run the application locally.
|
||||
- **Deployment Guide** explains how to deploy DeerFlow in your own environment.
|
||||
- **Configuration** covers the application-level configuration model.
|
||||
- **Workspace Usage** explains the main user workflows.
|
||||
- **Agents and Threads** explains how users interact with DeerFlow in practice.
|
||||
- **Operations and Troubleshooting** covers maintenance and production usage.
|
||||
|
||||
### Tutorials
|
||||
|
||||
The Tutorials section is for hands-on, task-oriented learning.
|
||||
|
||||
- [Tutorials](/docs/tutorials)
|
||||
|
||||
### Reference
|
||||
|
||||
The Reference section is for detailed lookup material, including configuration, runtime modes, APIs, and source-oriented mapping.
|
||||
|
||||
- [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).
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
"why-deerflow": {
|
||||
title: "Why DeerFlow",
|
||||
},
|
||||
"core-concepts": {
|
||||
title: "Core Concepts",
|
||||
},
|
||||
"harness-vs-app": {
|
||||
title: "Harness vs App",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Callout, Cards } from "nextra/components";
|
||||
|
||||
# Core Concepts
|
||||
|
||||
<Callout type="important" emoji="🧠">
|
||||
DeerFlow makes the most sense if you think of it as a runtime for long-horizon
|
||||
agents, not just a chat interface or a workflow graph.
|
||||
</Callout>
|
||||
|
||||
Before you go deeper into DeerFlow, it helps to anchor on a few concepts that appear throughout the system. These concepts explain what DeerFlow is optimizing for and why its architecture looks the way it does.
|
||||
|
||||
## Harness
|
||||
|
||||
In DeerFlow, a **harness** is the runtime layer that gives an agent the environment it needs to do real work.
|
||||
|
||||
A framework usually gives you abstractions and building blocks. A harness goes further: it packages an opinionated set of runtime capabilities so the agent can plan, act, use tools, manage files, and operate across longer tasks without you rebuilding the same infrastructure each time.
|
||||
|
||||
In practice, DeerFlow's harness includes things like:
|
||||
|
||||
- tool access,
|
||||
- skill loading,
|
||||
- sandboxed execution,
|
||||
- memory,
|
||||
- subagent orchestration, and
|
||||
- context management.
|
||||
|
||||
That is why DeerFlow is not only a model wrapper and not only a workflow graph. It is a runtime environment for agents.
|
||||
|
||||
## Long-horizon agent
|
||||
|
||||
A **long-horizon agent** is an agent that stays useful across a chain of actions instead of producing only a single answer.
|
||||
|
||||
This kind of agent may need to:
|
||||
|
||||
1. make a plan,
|
||||
2. decide the next step repeatedly,
|
||||
3. call tools many times,
|
||||
4. inspect and modify files,
|
||||
5. store intermediate results, and
|
||||
6. return a usable artifact at the end.
|
||||
|
||||
The important point is not just duration. It is sustained coordination across multiple steps.
|
||||
|
||||
DeerFlow is designed for this kind of work. Its architecture assumes that useful tasks often take more than one tool call and more than one reasoning pass.
|
||||
|
||||
## Skill
|
||||
|
||||
A **skill** is a task-oriented capability package that teaches the agent how to do a certain class of work.
|
||||
|
||||
A skill is not just a label. It usually includes structured instructions, workflows, best practices, and supporting resources that can be loaded when relevant. This keeps the base agent general while allowing specialized behavior to be added only when needed.
|
||||
|
||||
In DeerFlow, deep research is one skill. Data analysis, content generation, design-oriented workflows, and other task families can also be represented as skills.
|
||||
|
||||
This is a major part of the DeerFlow mental model: the runtime stays general, while skills provide specialization.
|
||||
|
||||
## Sandbox
|
||||
|
||||
A **sandbox** is the isolated execution environment where the agent does file and command-based work.
|
||||
|
||||
Instead of treating the agent as a pure text generator, DeerFlow gives it a workspace where it can read files, write outputs, run commands, and produce artifacts. This makes the system much more useful for coding, analysis, and multi-step workflows.
|
||||
|
||||
Isolation matters because execution should be controlled and reproducible. The sandbox is what lets DeerFlow support action, not just conversation.
|
||||
|
||||
## Subagent
|
||||
|
||||
A **subagent** is a focused worker that handles a delegated subtask.
|
||||
|
||||
When a task is too broad for one reasoning thread, DeerFlow can split the work into smaller units and run them separately. Subagents help with parallel exploration, scoped execution, and reducing overload on the main agent.
|
||||
|
||||
The key idea is isolation. A subagent does not need the full conversation history or every detail from the parent context. It only needs the information required to solve its assigned piece of work well.
|
||||
|
||||
## Context engineering
|
||||
|
||||
**Context engineering** is the practice of controlling what the agent sees, remembers, and ignores so it can stay effective over time.
|
||||
|
||||
Long tasks put pressure on the context window. If everything is kept inline forever, the agent becomes slower, noisier, and less reliable. DeerFlow addresses this with techniques such as summarization, scoped context for subagents, and using the file system as external working memory.
|
||||
|
||||
This is one of the most important ideas in DeerFlow. Good agent behavior is not only about a stronger model. It is also about giving the model the right working set at the right time.
|
||||
|
||||
## Memory
|
||||
|
||||
**Memory** is DeerFlow's mechanism for carrying useful information across sessions.
|
||||
|
||||
Instead of starting from zero every time, the system can retain information such as user preferences, recurring project context, and durable facts that improve future interactions. Memory makes the agent more adaptive and less repetitive.
|
||||
|
||||
In DeerFlow, memory is part of the runtime, not an afterthought layered on top.
|
||||
|
||||
## Artifact
|
||||
|
||||
An **artifact** is the concrete output of the agent's work.
|
||||
|
||||
That output might be a report, a generated file, a code change, a chart, a design asset, or another deliverable that can be reviewed and used outside the chat itself.
|
||||
|
||||
This matters because DeerFlow is designed around task completion, not just answer generation. The system is trying to produce something you can inspect, refine, or hand off.
|
||||
|
||||
## Putting the concepts together
|
||||
|
||||
These concepts fit together as one model:
|
||||
|
||||
- The **harness** provides the runtime.
|
||||
- **Skills** provide specialization.
|
||||
- The **sandbox** provides an execution environment.
|
||||
- **Subagents** provide decomposition and parallelism.
|
||||
- **Context engineering** keeps long tasks manageable.
|
||||
- **Memory** preserves useful continuity.
|
||||
- **Artifacts** are the outputs that make the work tangible.
|
||||
|
||||
If you keep that model in mind, the rest of the DeerFlow docs will be much easier to navigate.
|
||||
|
||||
<Cards num={2}>
|
||||
<Cards.Card title="Why DeerFlow" href="/docs/introduction/why-deerflow" />
|
||||
<Cards.Card title="Harness vs App" href="/docs/introduction/harness-vs-app" />
|
||||
</Cards>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Callout, Cards } from "nextra/components";
|
||||
|
||||
# Harness vs App
|
||||
|
||||
<Callout type="info" emoji="⚙️">
|
||||
DeerFlow App is the best-practice Super Agent application built on top of
|
||||
DeerFlow Harness, while DeerFlow Harness is the Python SDK and runtime
|
||||
foundation for building your own agent system.
|
||||
</Callout>
|
||||
|
||||
DeerFlow has two layers that are closely related but serve different purposes:
|
||||
|
||||
- **DeerFlow Harness** is the runtime foundation.
|
||||
- **DeerFlow App** is the best-practice application built on top of that foundation.
|
||||
|
||||
Understanding this distinction makes the rest of the documentation much easier to navigate.
|
||||
|
||||
## The Harness is the runtime layer
|
||||
|
||||
The **DeerFlow Harness** is the reusable system for building and operating long-horizon agents.
|
||||
|
||||
It is also delivered as a **Python library SDK**, so developers can use DeerFlow as a programmable foundation for building their own Super Agent systems instead of starting from scratch.
|
||||
|
||||
It provides the core runtime capabilities that make an agent useful in real work:
|
||||
|
||||
- skills,
|
||||
- tool use,
|
||||
- sandboxed execution,
|
||||
- memory,
|
||||
- subagent orchestration,
|
||||
- context management, and
|
||||
- configurable runtime behavior.
|
||||
|
||||
If you want to build your own agent product, integrate DeerFlow into an existing system, or customize the runtime deeply, the Harness is the part you should focus on.
|
||||
|
||||
The Harness is the foundation. It is the part that makes DeerFlow programmable, extensible, and reusable.
|
||||
|
||||
## The App is the reference implementation
|
||||
|
||||
The **DeerFlow App** is a complete Super Agent application built with the Harness.
|
||||
|
||||
Instead of only exposing runtime primitives, it shows what a production-oriented DeerFlow experience looks like when those primitives are assembled into a usable product. In other words, the App is not separate from the Harness. It is the best-practice implementation of what the Harness enables.
|
||||
|
||||
That is why the App is the easiest way to understand DeerFlow as a working system: it demonstrates how the runtime, UI, workflows, and operational pieces come together in one place.
|
||||
|
||||
## Why the App matters
|
||||
|
||||
Many teams do not just want agent infrastructure. They want a usable application that already solves the common product and operations problems around running agents.
|
||||
|
||||
The DeerFlow App addresses that need.
|
||||
|
||||
It presents DeerFlow as a **Super Agent application** rather than only a set of low-level building blocks. That means users interact with a complete system that can manage conversations, run tasks, produce artifacts, and expose the core DeerFlow capabilities through an opinionated product surface.
|
||||
|
||||
The App is therefore useful in two different ways:
|
||||
|
||||
1. as a ready-to-run product for teams that want to deploy DeerFlow directly, and
|
||||
2. as a reference architecture for teams that want to build their own application on top of the Harness.
|
||||
|
||||
## Best practices encoded in the App
|
||||
|
||||
The App is where DeerFlow's recommended patterns become concrete.
|
||||
|
||||
It reflects best practices for:
|
||||
|
||||
- how users interact with a lead agent,
|
||||
- how threads and artifacts are organized,
|
||||
- how runtime capabilities are exposed in a product workflow,
|
||||
- how the system is configured and operated, and
|
||||
- how a self-hosted DeerFlow deployment should be structured.
|
||||
|
||||
So when we say the App is built on the Harness, we do not only mean it imports the runtime. We mean it packages DeerFlow's preferred way to deliver a Super Agent experience end to end.
|
||||
|
||||
## Self-hosting and deployment
|
||||
|
||||
A key property of DeerFlow App is that it can be **self-hosted**.
|
||||
|
||||
That matters for teams that want control over infrastructure, data handling, runtime configuration, and integration with their own environment. The App is designed so you can run DeerFlow in your own setup instead of treating it as a closed hosted service.
|
||||
|
||||
This makes DeerFlow practical for internal tools, team workflows, and organization-specific deployments where control and extensibility matter as much as raw model capability.
|
||||
|
||||
## How to choose between them
|
||||
|
||||
The simplest rule is:
|
||||
|
||||
- Choose the **Harness** if you want to build your own agent system.
|
||||
- Choose the **App** if you want DeerFlow as a complete Super Agent product.
|
||||
- Use both if you want to start from the App while still keeping access to the underlying runtime.
|
||||
|
||||
The two layers are complementary. The Harness gives you the agent runtime. The App turns that runtime into a concrete product with deployment, operations, and user workflows already thought through.
|
||||
|
||||
<Cards num={2}>
|
||||
<Cards.Card title="DeerFlow Harness" href="/docs/harness" />
|
||||
<Cards.Card title="DeerFlow App" href="/docs/app" />
|
||||
</Cards>
|
||||
|
||||
## One foundation, two entry points
|
||||
|
||||
DeerFlow is not split into two unrelated products. It is one system with two entry points.
|
||||
|
||||
The **Harness** is the core runtime for developers.
|
||||
The **App** is the best-practice Super Agent application built on top of that runtime.
|
||||
|
||||
If you want to go deeper into the runtime itself, continue with the [DeerFlow Harness](/docs/harness). If you want to run DeerFlow as a product, continue with the [DeerFlow App](/docs/app).
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Callout, Cards } from "nextra/components";
|
||||
|
||||
# Why DeerFlow
|
||||
|
||||
<Callout type="info" emoji="🦌">
|
||||
DeerFlow started with deep research, but it grew into a general runtime for
|
||||
long-horizon agents that need skills, memory, tools, and coordination.
|
||||
</Callout>
|
||||
|
||||
DeerFlow exists because modern agent systems need more than a chat loop. A useful agent must plan over long horizons, break work into sub-tasks, use tools, manipulate files, run code safely, and preserve enough context to stay coherent across a complex task. DeerFlow was built to provide that runtime foundation.
|
||||
|
||||
## It started as deep research
|
||||
|
||||
The first version of DeerFlow was designed around a specific goal: produce real research outputs instead of lightweight chatbot summaries. The idea was to let an AI system work more like a research team: make a plan, gather sources, cross-check findings, and deliver a structured result with useful depth.
|
||||
|
||||
That framing worked, but the project quickly revealed something more important. Teams were not only using DeerFlow for research. They were adapting it for data analysis, report generation, internal automation, operations workflows, and other tasks that also require multi-step execution.
|
||||
|
||||
The common thread was clear: the valuable part was not only the research workflow itself, but the runtime capabilities underneath it.
|
||||
|
||||
## Research was the first skill, not the whole system
|
||||
|
||||
That shift in usage led to a key conclusion: deep research should be treated as one capability inside a broader agent runtime, not as the definition of the entire product.
|
||||
|
||||
DeerFlow therefore evolved from a project centered on a single research pattern into a general-purpose harness for long-running agents. In this model, research is still important, but it becomes one skill among many rather than the fixed shape of the system.
|
||||
|
||||
This is why DeerFlow is described as a **harness** instead of only a framework or only an application.
|
||||
|
||||
## Why a harness matters
|
||||
|
||||
A harness is an opinionated runtime for agents. It does not just expose abstractions. It packages the infrastructure an agent needs to do useful work in realistic environments.
|
||||
|
||||
For DeerFlow, that means combining the core pieces required for long-horizon execution:
|
||||
|
||||
- **Skills** for task-specific capabilities that can be loaded only when needed.
|
||||
- **Sandboxed execution** so agents can work with files, run commands, and produce artifacts safely.
|
||||
- **Subagents** so complex work can be decomposed and executed in parallel.
|
||||
- **Memory** so the system can retain user preferences and recurring context across sessions.
|
||||
- **Context management** so long tasks remain tractable even when conversations and outputs grow.
|
||||
|
||||
These are the building blocks that make an agent useful beyond a single prompt-response exchange.
|
||||
|
||||
## Why DeerFlow moved beyond fixed multi-agent graphs
|
||||
|
||||
Earlier agent systems often modeled work as a fixed graph of specialized roles. That approach can work for a narrow workflow, but it becomes rigid once users want the system to handle a broader range of tasks.
|
||||
|
||||
DeerFlow moved toward a different architecture: a lead agent with middleware, tools, and dynamically invoked subagents. This makes the system easier to extend because new capabilities can be introduced as skills, tools, or runtime policies instead of requiring the whole orchestration graph to be redesigned.
|
||||
|
||||
That architectural shift reflects the main motivation behind DeerFlow: build the reusable runtime layer first, then let many workflows sit on top of it.
|
||||
|
||||
## DeerFlow is built for long-horizon work
|
||||
|
||||
DeerFlow is motivated by a specific view of agents: the most valuable systems are not the ones that generate a single answer fastest, but the ones that can stay productive across a longer chain of actions.
|
||||
|
||||
A long-horizon agent needs to do more than respond. It needs to:
|
||||
|
||||
1. decide what to do next,
|
||||
2. keep track of intermediate state,
|
||||
3. store work outside the model context when necessary,
|
||||
4. recover from complexity without losing direction, and
|
||||
5. return an artifact that a human can review, refine, or continue from.
|
||||
|
||||
That is the category of problem DeerFlow is designed for.
|
||||
|
||||
## The goal
|
||||
|
||||
The goal of DeerFlow is to provide a solid foundation for building and operating agent systems that can actually do work.
|
||||
|
||||
If you are evaluating DeerFlow, the important idea is this: DeerFlow is not just a research demo and not just a UI wrapper around an LLM. It is a runtime harness for agents that need skills, memory, tools, isolation, and coordination to complete real tasks.
|
||||
|
||||
<Cards num={2}>
|
||||
<Cards.Card title="Core Concepts" href="/docs/introduction/core-concepts" />
|
||||
<Cards.Card title="Harness vs App" href="/docs/introduction/harness-vs-app" />
|
||||
</Cards>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
"concepts-glossary": {
|
||||
title: "Concepts Glossary",
|
||||
},
|
||||
"configuration-reference": {
|
||||
title: "Configuration Reference",
|
||||
},
|
||||
"api-gateway-reference": {
|
||||
title: "API / Gateway Reference",
|
||||
},
|
||||
"runtime-flags-and-modes": {
|
||||
title: "Runtime Flags and Modes",
|
||||
},
|
||||
"source-map": {
|
||||
title: "Source Map",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,3 @@
|
||||
# API / Gateway Reference
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Concepts Glossary
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration Reference
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Runtime Flags and Modes
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Source Map
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
"first-conversation": {
|
||||
title: "First Conversation",
|
||||
},
|
||||
"create-your-first-harness": {
|
||||
title: "Create Your First Harness",
|
||||
},
|
||||
"use-tools-and-skills": {
|
||||
title: "Use Tools and Skills",
|
||||
},
|
||||
"work-with-memory": {
|
||||
title: "Work with Memory",
|
||||
},
|
||||
"deploy-your-own-deerflow": {
|
||||
title: "Deploy Your Own DeerFlow",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Create Your First Harness
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Deploy Your Own DeerFlow
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# First Conversation
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Use Tools and Skills
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,3 @@
|
||||
# Work with Memory
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
index: {
|
||||
title: "概览",
|
||||
},
|
||||
workspace: {
|
||||
type: "page",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 概览
|
||||
description: 了解 DeerFlow,使用 Harness 构建,并部署应用。
|
||||
---
|
||||
|
||||
# 概览
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -30,8 +31,9 @@ export async function getAgent(name: string): Promise<Agent> {
|
||||
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...getCsrfHeaders() },
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
@@ -46,8 +48,9 @@ export async function updateAgent(
|
||||
): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...getCsrfHeaders() },
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
@@ -59,6 +62,8 @@ export async function updateAgent(
|
||||
export async function deleteAgent(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { getCsrfHeaders } from "./fetcher";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest: (_url, init) => ({
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...init.headers, ...getCsrfHeaders() },
|
||||
}),
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { buildLoginUrl } from "@/core/auth/types";
|
||||
|
||||
/**
|
||||
* Fetch with credentials. Automatically redirects to login on 401.
|
||||
*/
|
||||
export async function fetchWithAuth(
|
||||
input: RequestInfo | string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = buildLoginUrl(window.location.pathname);
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for CSRF-protected requests
|
||||
* Per RFC-001: Double Submit Cookie pattern
|
||||
*/
|
||||
export function getCsrfHeaders(): HeadersInit {
|
||||
const token = getCsrfToken();
|
||||
return token ? { "X-CSRF-Token": token } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token from cookie
|
||||
*/
|
||||
function getCsrfToken(): string | null {
|
||||
const match = /csrf_token=([^;]+)/.exec(document.cookie);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { type User, buildLoginUrl } from "./types";
|
||||
|
||||
// Re-export for consumers
|
||||
export type { User };
|
||||
|
||||
/**
|
||||
* Authentication context provided to consuming components
|
||||
*/
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
initialUser: User | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthProvider - Unified authentication context for the application
|
||||
*
|
||||
* Per RFC-001:
|
||||
* - Only holds display information (user), never JWT or tokens
|
||||
* - initialUser comes from server-side guard, avoiding client flicker
|
||||
* - Provides logout and refresh capabilities
|
||||
*/
|
||||
export function AuthProvider({ children, initialUser }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isAuthenticated = user !== null;
|
||||
|
||||
/**
|
||||
* Fetch current user from FastAPI
|
||||
* Used when initialUser might be stale (e.g., after tab was inactive)
|
||||
*/
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch("/api/v1/auth/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data);
|
||||
} else if (res.status === 401) {
|
||||
// Session expired or invalid
|
||||
setUser(null);
|
||||
// Redirect to login if on a protected route
|
||||
if (pathname?.startsWith("/workspace")) {
|
||||
router.push(buildLoginUrl(pathname));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh user:", err);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
/**
|
||||
* Logout - call FastAPI logout endpoint and clear local state
|
||||
* Per RFC-001: Immediately clear local state, don't wait for server confirmation
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
// Immediately clear local state to prevent UI flicker
|
||||
setUser(null);
|
||||
|
||||
try {
|
||||
await fetch("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Logout request failed:", err);
|
||||
// Still redirect even if logout request fails
|
||||
}
|
||||
|
||||
// Redirect to home page
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
/**
|
||||
* Handle visibility change - refresh user when tab becomes visible again.
|
||||
* Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches.
|
||||
*/
|
||||
const lastCheckRef = React.useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible" || user === null) return;
|
||||
const now = Date.now();
|
||||
if (now - lastCheckRef.current < 60_000) return;
|
||||
lastCheckRef.current = now;
|
||||
void refreshUser();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [user, refreshUser]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access authentication context
|
||||
* Throws if used outside AuthProvider - this is intentional for proper usage
|
||||
*/
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to require authentication - redirects to login if not authenticated
|
||||
* Useful for client-side checks in addition to server-side guards
|
||||
*/
|
||||
export function useRequireAuth(): AuthContextType {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if we're sure user is not authenticated (not just loading)
|
||||
if (!auth.isLoading && !auth.isAuthenticated) {
|
||||
router.push(buildLoginUrl(pathname || "/workspace"));
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.isLoading, router, pathname]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const gatewayConfigSchema = z.object({
|
||||
internalGatewayUrl: z.string().url(),
|
||||
trustedOrigins: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export type GatewayConfig = z.infer<typeof gatewayConfigSchema>;
|
||||
|
||||
let _cached: GatewayConfig | null = null;
|
||||
|
||||
export function getGatewayConfig(): GatewayConfig {
|
||||
if (_cached) return _cached;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim();
|
||||
const internalGatewayUrl =
|
||||
rawUrl?.replace(/\/+$/, "") ??
|
||||
(isDev ? "http://localhost:8001" : undefined);
|
||||
|
||||
const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim();
|
||||
const trustedOrigins = rawOrigins
|
||||
? rawOrigins
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: isDev
|
||||
? ["http://localhost:3000"]
|
||||
: undefined;
|
||||
|
||||
_cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins });
|
||||
return _cached;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ProxyPolicy {
|
||||
/** Allowed upstream path prefixes */
|
||||
readonly allowedPaths: readonly string[];
|
||||
/** Request headers to strip before forwarding */
|
||||
readonly strippedRequestHeaders: ReadonlySet<string>;
|
||||
/** Response headers to strip before returning */
|
||||
readonly strippedResponseHeaders: ReadonlySet<string>;
|
||||
/** Credential mode: which cookie to forward */
|
||||
readonly credential: { readonly type: "cookie"; readonly name: string };
|
||||
/** Timeout in ms */
|
||||
readonly timeoutMs: number;
|
||||
/** CSRF: required for non-GET/HEAD */
|
||||
readonly csrf: boolean;
|
||||
}
|
||||
|
||||
export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = {
|
||||
allowedPaths: [
|
||||
"threads",
|
||||
"runs",
|
||||
"assistants",
|
||||
"store",
|
||||
"models",
|
||||
"mcp",
|
||||
"skills",
|
||||
"memory",
|
||||
],
|
||||
strippedRequestHeaders: new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"origin",
|
||||
"referer",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
]),
|
||||
strippedResponseHeaders: new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
]),
|
||||
credential: { type: "cookie", name: "access_token" },
|
||||
timeoutMs: 120_000,
|
||||
csrf: true,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { getGatewayConfig } from "./gateway-config";
|
||||
import { type AuthResult, userSchema } from "./types";
|
||||
|
||||
const SSR_AUTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Fetch the authenticated user from the gateway using the request's cookies.
|
||||
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
|
||||
*/
|
||||
export async function getServerSideUser(): Promise<AuthResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get("access_token");
|
||||
|
||||
let internalGatewayUrl: string;
|
||||
try {
|
||||
internalGatewayUrl = getGatewayConfig().internalGatewayUrl;
|
||||
} catch (err) {
|
||||
return { tag: "config_error", message: String(err) };
|
||||
}
|
||||
|
||||
if (!sessionCookie) return { tag: "unauthenticated" };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {
|
||||
headers: { Cookie: `access_token=${sessionCookie.value}` },
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout); // Clear immediately — covers all response branches
|
||||
|
||||
if (res.ok) {
|
||||
const parsed = userSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
console.error("[SSR auth] Malformed /auth/me response:", parsed.error);
|
||||
return { tag: "gateway_unavailable" };
|
||||
}
|
||||
if (parsed.data.needs_setup) {
|
||||
return { tag: "needs_setup", user: parsed.data };
|
||||
}
|
||||
return { tag: "authenticated", user: parsed.data };
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { tag: "unauthenticated" };
|
||||
}
|
||||
console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`);
|
||||
return { tag: "gateway_unavailable" };
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[SSR auth] Failed to reach gateway:", err);
|
||||
return { tag: "gateway_unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ── User schema (single source of truth) ──────────────────────────
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
system_role: z.enum(["admin", "user"]),
|
||||
needs_setup: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
// ── SSR auth result (tagged union) ────────────────────────────────
|
||||
|
||||
export type AuthResult =
|
||||
| { tag: "authenticated"; user: User }
|
||||
| { tag: "needs_setup"; user: User }
|
||||
| { tag: "unauthenticated" }
|
||||
| { tag: "gateway_unavailable" }
|
||||
| { tag: "config_error"; message: string };
|
||||
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`);
|
||||
}
|
||||
|
||||
export function buildLoginUrl(returnPath: string): string {
|
||||
return `/login?next=${encodeURIComponent(returnPath)}`;
|
||||
}
|
||||
|
||||
// ── Backend error response parsing ────────────────────────────────
|
||||
|
||||
const AUTH_ERROR_CODES = [
|
||||
"invalid_credentials",
|
||||
"token_expired",
|
||||
"token_invalid",
|
||||
"user_not_found",
|
||||
"email_already_exists",
|
||||
"provider_not_found",
|
||||
"not_authenticated",
|
||||
] as const;
|
||||
|
||||
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number];
|
||||
|
||||
export interface AuthErrorResponse {
|
||||
code: AuthErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const authErrorSchema = z.object({
|
||||
code: z.enum(AUTH_ERROR_CODES),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export function parseAuthError(data: unknown): AuthErrorResponse {
|
||||
// Try top-level {code, message} first
|
||||
const parsed = authErrorSchema.safeParse(data);
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
// Unwrap FastAPI's {detail: {code, message}} envelope
|
||||
if (typeof data === "object" && data !== null && "detail" in data) {
|
||||
const detail = (data as Record<string, unknown>).detail;
|
||||
const nested = authErrorSchema.safeParse(detail);
|
||||
if (nested.success) return nested.data;
|
||||
// Legacy string-detail responses
|
||||
if (typeof detail === "string") {
|
||||
return { code: "invalid_credentials", message: detail };
|
||||
}
|
||||
}
|
||||
|
||||
return { code: "invalid_credentials", message: "Authentication failed" };
|
||||
}
|
||||
@@ -4,22 +4,15 @@ import { useEffect } from "react";
|
||||
|
||||
import { useI18nContext } from "./context";
|
||||
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
|
||||
import { enUS } from "./locales/en-US";
|
||||
import { zhCN } from "./locales/zh-CN";
|
||||
import { translations } from "./translations";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
detectLocale,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Translations,
|
||||
} from "./index";
|
||||
|
||||
const translations: Record<Locale, Translations> = {
|
||||
"en-US": enUS,
|
||||
"zh-CN": zhCN,
|
||||
};
|
||||
|
||||
export function useI18n() {
|
||||
const { locale, setLocale } = useI18nContext();
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ export function isLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getLocaleByLang(lang: string): Locale {
|
||||
const normalizedLang = lang.toLowerCase();
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (locale.startsWith(normalizedLang)) {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function normalizeLocale(locale: string | null | undefined): Locale {
|
||||
if (!locale) {
|
||||
return DEFAULT_LOCALE;
|
||||
|
||||
@@ -51,6 +51,12 @@ export const enUS: Translations = {
|
||||
exportSuccess: "Conversation exported",
|
||||
},
|
||||
|
||||
// Home
|
||||
home: {
|
||||
docs: "Docs",
|
||||
blog: "Blog",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
welcome: {
|
||||
greeting: "Hello, again!",
|
||||
@@ -199,6 +205,17 @@ export const enUS: Translations = {
|
||||
nameStepCheckError: "Could not verify name availability — please try again",
|
||||
nameStepBootstrapMessage:
|
||||
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
|
||||
save: "Save agent",
|
||||
saving: "Saving agent...",
|
||||
saveRequested:
|
||||
"Save requested. DeerFlow is generating and saving an initial version now.",
|
||||
saveHint:
|
||||
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
||||
saveCommandMessage:
|
||||
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
|
||||
agentCreatedPendingRefresh:
|
||||
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
|
||||
more: "More actions",
|
||||
agentCreated: "Agent created!",
|
||||
startChatting: "Start chatting",
|
||||
backToGallery: "Back to Gallery",
|
||||
@@ -219,6 +236,7 @@ export const enUS: Translations = {
|
||||
reportIssue: "Report a issue",
|
||||
contactUs: "Contact us",
|
||||
about: "About DeerFlow",
|
||||
logout: "Log out",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -303,6 +321,7 @@ export const enUS: Translations = {
|
||||
title: "Settings",
|
||||
description: "Adjust how DeerFlow looks and behaves for you.",
|
||||
sections: {
|
||||
account: "Account",
|
||||
appearance: "Appearance",
|
||||
memory: "Memory",
|
||||
tools: "Tools",
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface Translations {
|
||||
exportSuccess: string;
|
||||
};
|
||||
|
||||
home: {
|
||||
docs: string;
|
||||
blog: string;
|
||||
};
|
||||
|
||||
// Welcome
|
||||
welcome: {
|
||||
greeting: string;
|
||||
@@ -136,6 +141,13 @@ export interface Translations {
|
||||
nameStepNetworkError: string;
|
||||
nameStepCheckError: string;
|
||||
nameStepBootstrapMessage: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
saveRequested: string;
|
||||
saveHint: string;
|
||||
saveCommandMessage: string;
|
||||
agentCreatedPendingRefresh: string;
|
||||
more: string;
|
||||
agentCreated: string;
|
||||
startChatting: string;
|
||||
backToGallery: string;
|
||||
@@ -156,6 +168,7 @@ export interface Translations {
|
||||
reportIssue: string;
|
||||
contactUs: string;
|
||||
about: string;
|
||||
logout: string;
|
||||
};
|
||||
|
||||
// Conversation
|
||||
@@ -238,6 +251,7 @@ export interface Translations {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: {
|
||||
account: string;
|
||||
appearance: string;
|
||||
memory: string;
|
||||
tools: string;
|
||||
|
||||
@@ -51,6 +51,12 @@ export const zhCN: Translations = {
|
||||
exportSuccess: "对话已导出",
|
||||
},
|
||||
|
||||
// Home
|
||||
home: {
|
||||
docs: "文档",
|
||||
blog: "博客",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
welcome: {
|
||||
greeting: "你好,欢迎回来!",
|
||||
@@ -187,6 +193,17 @@ export const zhCN: Translations = {
|
||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||
nameStepBootstrapMessage:
|
||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存智能体",
|
||||
saving: "正在保存智能体...",
|
||||
saveRequested:
|
||||
"已提交保存请求,DeerFlow 正在根据当前对话生成并保存初版智能体。",
|
||||
saveHint:
|
||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||
saveCommandMessage:
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
agentCreatedPendingRefresh:
|
||||
"智能体已创建,但 DeerFlow 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
more: "更多操作",
|
||||
agentCreated: "智能体已创建!",
|
||||
startChatting: "开始对话",
|
||||
backToGallery: "返回 Gallery",
|
||||
@@ -207,6 +224,7 @@ export const zhCN: Translations = {
|
||||
reportIssue: "报告问题",
|
||||
contactUs: "联系我们",
|
||||
about: "关于 DeerFlow",
|
||||
logout: "退出登录",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -288,6 +306,7 @@ export const zhCN: Translations = {
|
||||
title: "设置",
|
||||
description: "根据你的偏好调整 DeerFlow 的界面和行为。",
|
||||
sections: {
|
||||
account: "账号",
|
||||
appearance: "外观",
|
||||
memory: "记忆",
|
||||
tools: "工具",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { normalizeLocale, type Locale } from "./locale";
|
||||
import { DEFAULT_LOCALE, normalizeLocale, type Locale } from "./locale";
|
||||
import { translations } from "./translations";
|
||||
|
||||
export async function detectLocaleServer(): Promise<Locale> {
|
||||
const cookieStore = await cookies();
|
||||
@@ -15,3 +16,26 @@ export async function detectLocaleServer(): Promise<Locale> {
|
||||
|
||||
return normalizeLocale(locale);
|
||||
}
|
||||
|
||||
export async function setLocale(locale: string | Locale): Promise<Locale> {
|
||||
const normalizedLocale = normalizeLocale(locale);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("locale", encodeURIComponent(normalizedLocale), {
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return normalizedLocale;
|
||||
}
|
||||
|
||||
export async function getI18n(localeOverride?: string | Locale) {
|
||||
const locale = localeOverride
|
||||
? normalizeLocale(localeOverride)
|
||||
: await detectLocaleServer();
|
||||
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
return {
|
||||
locale,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Locale } from "./locale";
|
||||
import { enUS, zhCN, type Translations } from "./locales";
|
||||
|
||||
export const translations: Record<Locale, Translations> = {
|
||||
"en-US": enUS,
|
||||
"zh-CN": zhCN,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { MCPConfig } from "./types";
|
||||
@@ -12,8 +13,10 @@ export async function updateMCPConfig(config: MCPConfig) {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
credentials: "include",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCsrfHeaders } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
import type {
|
||||
@@ -87,6 +88,8 @@ export async function loadMemory(): Promise<UserMemory> {
|
||||
export async function clearMemory(): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
|
||||
method: "DELETE",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: "include",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to clear memory");
|
||||
}
|
||||
@@ -96,6 +99,8 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to delete memory fact");
|
||||
@@ -111,8 +116,10 @@ export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify(memory),
|
||||
credentials: "include",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to import memory");
|
||||
}
|
||||
@@ -124,8 +131,10 @@ export async function createMemoryFact(
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to create memory fact");
|
||||
}
|
||||
@@ -140,8 +149,10 @@ export async function updateMemoryFact(
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to update memory fact");
|
||||
|
||||
@@ -52,6 +52,10 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (isHiddenFromUIMessage(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.name === "todo_reminder") {
|
||||
continue;
|
||||
}
|
||||
@@ -323,6 +327,10 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isHiddenFromUIMessage(message: Message) {
|
||||
return message.additional_kwargs?.hide_from_ui === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file stored in message additional_kwargs.files.
|
||||
* Used for optimistic UI (uploading state) and structured file metadata.
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMemo } from "react";
|
||||
import { visit } from "unist-util-visit";
|
||||
import type { BuildVisitor } from "unist-util-visit";
|
||||
|
||||
const CJK_TEXT_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
export function rehypeSplitWordsIntoSpans() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, "element", ((node: Element) => {
|
||||
@@ -15,6 +18,10 @@ export function rehypeSplitWordsIntoSpans() {
|
||||
const newChildren: Array<ElementContent> = [];
|
||||
node.children.forEach((child) => {
|
||||
if (child.type === "text") {
|
||||
if (CJK_TEXT_RE.test(child.value)) {
|
||||
newChildren.push(child);
|
||||
return;
|
||||
}
|
||||
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
||||
const segments = segmenter.segment(child.value);
|
||||
const words = Array.from(segments)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
@@ -15,10 +16,12 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
}),
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return response.json();
|
||||
@@ -42,8 +45,10 @@ export async function installSkill(
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { uploadFiles } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
@@ -32,6 +32,10 @@ export type ThreadStreamOptions = {
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
@@ -218,6 +222,7 @@ export function useThreadStream({
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
if (sendInFlightRef.current) {
|
||||
return;
|
||||
@@ -238,17 +243,23 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
// Create optimistic human message (shown immediately)
|
||||
const optimisticHumanMsg: Message = {
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs:
|
||||
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
const newOptimistic: Message[] = [optimisticHumanMsg];
|
||||
if (optimisticFiles.length > 0) {
|
||||
const newOptimistic: Message[] = [];
|
||||
if (!hideFromUI) {
|
||||
newOptimistic.push({
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs: optimisticAdditionalKwargs,
|
||||
});
|
||||
}
|
||||
|
||||
if (optimisticFiles.length > 0 && !hideFromUI) {
|
||||
// Mock AI message while files are being uploaded
|
||||
newOptimistic.push({
|
||||
type: "ai",
|
||||
@@ -268,28 +279,9 @@ export function useThreadStream({
|
||||
if (message.files && message.files.length > 0) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Convert FileUIPart to File objects by fetching blob URLs
|
||||
const filePromises = message.files.map(async (fileUIPart) => {
|
||||
if (fileUIPart.url && fileUIPart.filename) {
|
||||
try {
|
||||
// Fetch the blob URL to get the file data
|
||||
const response = await fetch(fileUIPart.url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a File object from the blob
|
||||
return new File([blob], fileUIPart.filename, {
|
||||
type: fileUIPart.mediaType || blob.type,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to fetch file ${fileUIPart.filename}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const filePromises = message.files.map((fileUIPart) =>
|
||||
promptInputFilePartToFile(fileUIPart),
|
||||
);
|
||||
|
||||
const conversionResults = await Promise.all(filePromises);
|
||||
const files = conversionResults.filter(
|
||||
@@ -335,7 +327,6 @@ export function useThreadStream({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to upload files:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -369,8 +360,12 @@ export function useThreadStream({
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs:
|
||||
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -498,10 +493,13 @@ export function useDeleteThread() {
|
||||
mutationFn: async ({ threadId }: { threadId: string }) => {
|
||||
await apiClient.threads.delete(threadId);
|
||||
|
||||
const { getCsrfHeaders } = await import("@/core/api/fetcher");
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* API functions for file uploads
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
export interface UploadedFileInfo {
|
||||
@@ -54,7 +55,9 @@ export async function uploadFiles(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -95,6 +98,8 @@ export async function deleteUploadedFile(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MACOS_APP_BUNDLE_UPLOAD_MESSAGE,
|
||||
isLikelyMacOSAppBundle,
|
||||
splitUnsupportedUploadFiles,
|
||||
} from "./file-validation.ts";
|
||||
|
||||
test("identifies Finder-style .app bundle uploads as unsupported", () => {
|
||||
assert.equal(
|
||||
isLikelyMacOSAppBundle({
|
||||
name: "Vibe Island.app",
|
||||
type: "application/octet-stream",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps normal files and reports rejected app bundles", () => {
|
||||
const files = [
|
||||
new File(["demo"], "Vibe Island.app", {
|
||||
type: "application/octet-stream",
|
||||
}),
|
||||
new File(["notes"], "notes.txt", { type: "text/plain" }),
|
||||
];
|
||||
|
||||
const result = splitUnsupportedUploadFiles(files);
|
||||
|
||||
assert.equal(result.accepted.length, 1);
|
||||
assert.equal(result.accepted[0]?.name, "notes.txt");
|
||||
assert.equal(result.rejected.length, 1);
|
||||
assert.equal(result.rejected[0]?.name, "Vibe Island.app");
|
||||
assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE);
|
||||
});
|
||||
|
||||
test("treats empty MIME .app uploads as unsupported", () => {
|
||||
const result = splitUnsupportedUploadFiles([
|
||||
new File(["demo"], "Another.app", { type: "" }),
|
||||
]);
|
||||
|
||||
assert.equal(result.accepted.length, 0);
|
||||
assert.equal(result.rejected.length, 1);
|
||||
assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE);
|
||||
});
|
||||
|
||||
test("returns no message when every file is supported", () => {
|
||||
const result = splitUnsupportedUploadFiles([
|
||||
new File(["notes"], "notes.txt", { type: "text/plain" }),
|
||||
]);
|
||||
|
||||
assert.equal(result.accepted.length, 1);
|
||||
assert.equal(result.rejected.length, 0);
|
||||
assert.equal(result.message, undefined);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([
|
||||
"",
|
||||
"application/octet-stream",
|
||||
]);
|
||||
|
||||
export const MACOS_APP_BUNDLE_UPLOAD_MESSAGE =
|
||||
"macOS .app bundles can't be uploaded directly from the browser. Compress the app as a .zip or upload the .dmg instead.";
|
||||
|
||||
export function isLikelyMacOSAppBundle(file: Pick<File, "name" | "type">) {
|
||||
return (
|
||||
file.name.toLowerCase().endsWith(".app") &&
|
||||
MACOS_APP_BUNDLE_CONTENT_TYPES.has(file.type)
|
||||
);
|
||||
}
|
||||
|
||||
export function splitUnsupportedUploadFiles(fileList: File[] | FileList) {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted: File[] = [];
|
||||
const rejected: File[] = [];
|
||||
|
||||
for (const file of incoming) {
|
||||
if (isLikelyMacOSAppBundle(file)) {
|
||||
rejected.push(file);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
}
|
||||
|
||||
return {
|
||||
accepted,
|
||||
rejected,
|
||||
message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined,
|
||||
};
|
||||
}
|
||||
@@ -3,4 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./api";
|
||||
export * from "./file-validation";
|
||||
export * from "./hooks";
|
||||
export * from "./prompt-input-files";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadModule() {
|
||||
try {
|
||||
return await import("./prompt-input-files.ts");
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
test("exports the prompt-input file conversion helper", async () => {
|
||||
const loaded = await loadModule();
|
||||
|
||||
assert.ok(
|
||||
!("error" in loaded),
|
||||
loaded.error instanceof Error
|
||||
? loaded.error.message
|
||||
: "prompt-input-files module is missing",
|
||||
);
|
||||
assert.equal(typeof loaded.promptInputFilePartToFile, "function");
|
||||
});
|
||||
|
||||
test("reuses the original File when a prompt attachment already has one", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
const file = new File(["hello"], "note.txt", { type: "text/plain" });
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("fetch should not run when File is already present");
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
filename: file.name,
|
||||
mediaType: file.type,
|
||||
url: "blob:http://localhost:2026/stale-preview-url",
|
||||
file,
|
||||
});
|
||||
|
||||
assert.equal(converted, file);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("reconstructs a File from a data URL when no original File is present", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
filename: "note.txt",
|
||||
mediaType: "text/plain",
|
||||
url: "data:text/plain;base64,aGVsbG8=",
|
||||
});
|
||||
|
||||
assert.ok(converted);
|
||||
assert.equal(converted.name, "note.txt");
|
||||
assert.equal(converted.type, "text/plain");
|
||||
assert.equal(await converted.text(), "hello");
|
||||
});
|
||||
|
||||
test("rewraps the original File when the prompt metadata changes", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
const file = new File(["hello"], "note.txt", { type: "text/plain" });
|
||||
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
filename: "renamed.txt",
|
||||
mediaType: "text/markdown",
|
||||
file,
|
||||
});
|
||||
|
||||
assert.ok(converted);
|
||||
assert.notEqual(converted, file);
|
||||
assert.equal(converted.name, "renamed.txt");
|
||||
assert.equal(converted.type, "text/markdown");
|
||||
assert.equal(await converted.text(), "hello");
|
||||
});
|
||||
|
||||
test("returns null when upload preparation is missing required data", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
mediaType: "text/plain",
|
||||
});
|
||||
|
||||
assert.equal(converted, null);
|
||||
});
|
||||
|
||||
test("returns null when the URL fallback fetch fails", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const warnCalls = [];
|
||||
|
||||
console.warn = (...args) => {
|
||||
warnCalls.push(args);
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
filename: "note.txt",
|
||||
url: "blob:http://localhost:2026/missing-preview-url",
|
||||
});
|
||||
|
||||
assert.equal(converted, null);
|
||||
assert.equal(warnCalls.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when the URL fallback fetch response is non-ok", async () => {
|
||||
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const warnCalls = [];
|
||||
|
||||
console.warn = (...args) => {
|
||||
warnCalls.push(args);
|
||||
};
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("missing", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
|
||||
try {
|
||||
const converted = await promptInputFilePartToFile({
|
||||
type: "file",
|
||||
filename: "note.txt",
|
||||
url: "blob:http://localhost:2026/missing-preview-url",
|
||||
});
|
||||
|
||||
assert.equal(converted, null);
|
||||
assert.equal(warnCalls.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FileUIPart } from "ai";
|
||||
|
||||
export type PromptInputFilePart = FileUIPart & {
|
||||
// Transient submit-time handle to the original browser File; not serializable.
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export async function promptInputFilePartToFile(
|
||||
filePart: PromptInputFilePart,
|
||||
): Promise<File | null> {
|
||||
if (filePart.file instanceof File) {
|
||||
const filename =
|
||||
typeof filePart.filename === "string" && filePart.filename.length > 0
|
||||
? filePart.filename
|
||||
: filePart.file.name;
|
||||
const mediaType =
|
||||
typeof filePart.mediaType === "string" && filePart.mediaType.length > 0
|
||||
? filePart.mediaType
|
||||
: filePart.file.type;
|
||||
|
||||
if (filePart.file.name === filename && filePart.file.type === mediaType) {
|
||||
return filePart.file;
|
||||
}
|
||||
|
||||
return new File([filePart.file], filename, { type: mediaType });
|
||||
}
|
||||
|
||||
if (!filePart.url || !filePart.filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(filePart.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${response.status} while fetching fallback file URL`,
|
||||
);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
return new File([blob], filePart.filename, {
|
||||
type: filePart.mediaType || blob.type,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("promptInputFilePartToFile: fetch fallback failed", {
|
||||
error,
|
||||
url: filePart.url,
|
||||
filename: filePart.filename,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useMDXComponents as getThemeComponents } from "nextra-theme-docs"; // nextra-theme-blog or your custom theme
|
||||
|
||||
// Get the default MDX components
|
||||
const themeComponents = getThemeComponents();
|
||||
|
||||
// Merge components
|
||||
export function useMDXComponents() {
|
||||
return {
|
||||
...themeComponents,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
|
||||
export type Session = typeof authClient.$Infer.Session;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
@@ -1 +0,0 @@
|
||||
export { auth } from "./config";
|
||||
@@ -1,8 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
import { auth } from ".";
|
||||
|
||||
export const getSession = cache(async () =>
|
||||
auth.api.getSession({ headers: await headers() }),
|
||||
);
|
||||
Reference in New Issue
Block a user