Initial commit: hardened DeerFlow factory

Vendored deer-flow upstream (bytedance/deer-flow) plus prompt-injection
hardening:

- New deerflow.security package: content_delimiter, html_cleaner,
  sanitizer (8 layers — invisible chars, control chars, symbols, NFC,
  PUA, tag chars, horizontal whitespace collapse with newline/tab
  preservation, length cap)
- New deerflow.community.searx package: web_search, web_fetch,
  image_search backed by a private SearX instance, every external
  string sanitized and wrapped in <<<EXTERNAL_UNTRUSTED_CONTENT>>>
  delimiters
- All native community web providers (ddg_search, tavily, exa,
  firecrawl, jina_ai, infoquest, image_search) replaced with hard-fail
  stubs that raise NativeWebToolDisabledError at import time, so a
  misconfigured tool.use path fails loud rather than silently falling
  back to unsanitized output
- Native client back-doors (jina_client.py, infoquest_client.py)
  stubbed too
- Native-tool tests quarantined under tests/_disabled_native/
  (collect_ignore_glob via local conftest.py)
- Sanitizer Layer 7 fix: only collapse horizontal whitespace, preserve
  newlines and tabs so list/table structure survives
- Hardened runtime config.yaml references only the searx-backed tools
- Factory overlay (backend/) kept in sync with deer-flow tree as a
  reference / source

See HARDENING.md for the full audit trail and verification steps.
This commit is contained in:
2026-04-12 14:23:57 +02:00
commit 6de0bf9f5b
889 changed files with 173052 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -0,0 +1,51 @@
import type { PageMapItem } from "nextra";
import { getPageMap } from "nextra/page-map";
import { Layout } from "nextra-theme-docs";
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { getLocaleByLang } from "@/core/i18n/locale";
import "nextra-theme-docs/style.css";
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.startsWith(base)) {
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}`);
const pageMap = formatPageRoute(`/${lang}/docs`, pages);
return (
<Layout
navbar={
<Header
className="relative max-w-full px-10"
homeURL="/"
locale={locale}
/>
}
pageMap={pageMap}
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content"
footer={<Footer />}
i18n={i18n}
// ... Your additional layout options
>
{children}
</Layout>
);
}

View File

@@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/server/better-auth";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,55 @@
import type { NextRequest } from "next/server";
const BACKEND_BASE_URL =
process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8001";
function buildBackendUrl(pathname: string) {
return new URL(pathname, BACKEND_BASE_URL);
}
async function proxyRequest(request: NextRequest, pathname: string) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("content-length");
const hasBody = !["GET", "HEAD"].includes(request.method);
const response = await fetch(buildBackendUrl(pathname), {
method: request.method,
headers,
body: hasBody ? await request.arrayBuffer() : undefined,
});
return new Response(await response.arrayBuffer(), {
status: response.status,
headers: response.headers,
});
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
}

View File

@@ -0,0 +1,35 @@
import type { NextRequest } from "next/server";
const BACKEND_BASE_URL =
process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8001";
function buildBackendUrl(pathname: string) {
return new URL(pathname, BACKEND_BASE_URL);
}
async function proxyRequest(request: NextRequest, pathname: string) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("content-length");
const hasBody = !["GET", "HEAD"].includes(request.method);
const response = await fetch(buildBackendUrl(pathname), {
method: request.method,
headers,
body: hasBody ? await request.arrayBuffer() : undefined,
});
return new Response(await response.arrayBuffer(), {
status: response.status,
headers: response.headers,
});
}
export async function GET(request: NextRequest) {
return proxyRequest(request, "/api/memory");
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request, "/api/memory");
}

View File

@@ -0,0 +1,178 @@
import { notFound } from "next/navigation";
import { importPage } from "nextra/pages";
import { cache } from "react";
import { PostList, PostMeta } from "@/components/landing/post-list";
import {
BLOG_LANGS,
type BlogLang,
formatTagName,
getAllPosts,
getBlogIndexData,
getPreferredBlogLang,
} from "@/core/blog";
import { getI18n } from "@/core/i18n/server";
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
// eslint-disable-next-line @typescript-eslint/unbound-method
const Wrapper = getMDXComponents().wrapper;
function isBlogLang(value: string): value is BlogLang {
return BLOG_LANGS.includes(value as BlogLang);
}
const loadBlogPage = cache(async function loadBlogPage(
mdxPath: string[] | undefined,
preferredLang?: (typeof BLOG_LANGS)[number],
) {
const slug = mdxPath ?? [];
const matches = await Promise.all(
BLOG_LANGS.map(async (lang) => {
try {
// Try every localized source for the same public /blog slug,
// then pick the best match for the current locale.
const page = await importPage([...slug], lang);
return { lang, page };
} catch {
return null;
}
}),
);
const availableMatches = matches.filter(
(match): match is NonNullable<(typeof matches)[number]> => match !== null,
);
if (availableMatches.length === 0) {
return null;
}
const selected =
(preferredLang
? availableMatches.find(({ lang }) => lang === preferredLang)
: undefined) ?? availableMatches[0];
if (!selected) {
return null;
}
return {
...selected.page,
lang: selected.lang,
metadata: {
...selected.page.metadata,
languages: availableMatches.map(({ lang }) => lang),
},
slug,
};
});
export async function generateMetadata(props) {
const params = await props.params;
const mdxPath = params.mdxPath ?? [];
const { locale } = await getI18n();
const preferredLang = getPreferredBlogLang(locale);
if (mdxPath.length === 0) {
return {
title: "Blog",
};
}
if (mdxPath[0] === "tags" && mdxPath[1]) {
return {
title: formatTagName(mdxPath[1]),
};
}
const page = await loadBlogPage(mdxPath, preferredLang);
if (!page) {
return {};
}
return page.metadata;
}
export default async function Page(props) {
const params = await props.params;
const searchParams = await props.searchParams;
const mdxPath = params.mdxPath ?? [];
const { locale } = await getI18n();
const localePreferredLang = getPreferredBlogLang(locale);
const queryLang = searchParams?.lang;
const preferredLang =
typeof queryLang === "string" && isBlogLang(queryLang)
? queryLang
: localePreferredLang;
if (mdxPath.length === 0) {
const posts = await getAllPosts(preferredLang);
return (
<Wrapper
toc={[]}
metadata={{ title: "All Posts", filePath: "blog/index.mdx" }}
sourceCode=""
>
<PostList title="All Posts" posts={posts} />
</Wrapper>
);
}
if (mdxPath[0] === "tags" && mdxPath[1]) {
let tag: string;
try {
tag = decodeURIComponent(mdxPath[1]);
} catch {
notFound();
}
const title = formatTagName(tag);
const { posts } = await getBlogIndexData(preferredLang, { tag });
if (posts.length === 0) {
notFound();
}
return (
<Wrapper
toc={[]}
metadata={{ title, filePath: "blog/index.mdx" }}
sourceCode=""
>
<PostList
title={title}
description={`${posts.length} posts with the tag “${title}`}
posts={posts}
/>
</Wrapper>
);
}
const page = await loadBlogPage(mdxPath, preferredLang);
if (!page) {
notFound();
}
const { default: MDXContent, toc, metadata, sourceCode, lang, slug } = page;
const postMetaData = metadata as {
date?: string;
languages?: string[];
tags?: unknown;
};
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<PostMeta
currentLang={lang}
date={
typeof postMetaData.date === "string" ? postMetaData.date : undefined
}
languages={postMetaData.languages}
pathname={slug.length === 0 ? "/blog" : `/blog/${slug.join("/")}`}
/>
<MDXContent {...props} params={{ ...params, lang, mdxPath: slug }} />
</Wrapper>
);
}

View File

@@ -0,0 +1,22 @@
import { Layout } from "nextra-theme-docs";
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { getBlogIndexData } from "@/core/blog";
import "nextra-theme-docs/style.css";
export default async function BlogLayout({ children }) {
const { pageMap } = await getBlogIndexData();
return (
<Layout
navbar={<Header className="relative max-w-full px-10" homeURL="/" />}
pageMap={pageMap}
sidebar={{ defaultOpen: true }}
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content"
footer={<Footer />}
>
{children}
</Layout>
);
}

View File

@@ -0,0 +1,24 @@
import { PostList } from "@/components/landing/post-list";
import { getAllPosts, getPreferredBlogLang } from "@/core/blog";
import { getI18n } from "@/core/i18n/server";
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
// eslint-disable-next-line @typescript-eslint/unbound-method
const Wrapper = getMDXComponents().wrapper;
export const metadata = {
title: "All Posts",
filePath: "blog/index.mdx",
};
export default async function PostsPage() {
const { locale } = await getI18n();
const posts = await getAllPosts(getPreferredBlogLang(locale));
return (
<Wrapper toc={[]} metadata={metadata} sourceCode="">
<PostList title={metadata.title} posts={posts} />
</Wrapper>
);
}

View File

@@ -0,0 +1,51 @@
import { notFound } from "next/navigation";
import { PostList } from "@/components/landing/post-list";
import {
formatTagName,
getBlogIndexData,
getPreferredBlogLang,
} from "@/core/blog";
import { getI18n } from "@/core/i18n/server";
import { useMDXComponents as getMDXComponents } from "../../../../mdx-components";
// eslint-disable-next-line @typescript-eslint/unbound-method
const Wrapper = getMDXComponents().wrapper;
export async function generateMetadata(props) {
const params = await props.params;
return {
title: formatTagName(params.tag),
filePath: "blog/index.mdx",
};
}
export default async function TagPage(props) {
const params = await props.params;
const tag = params.tag;
const { locale } = await getI18n();
const { posts } = await getBlogIndexData(getPreferredBlogLang(locale), {
tag,
});
if (posts.length === 0) {
notFound();
}
const title = formatTagName(tag);
return (
<Wrapper
toc={[]}
metadata={{ title, filePath: "blog/index.mdx" }}
sourceCode=""
>
<PostList
title={title}
description={`${posts.length} posts with the tag “${title}`}
posts={posts}
/>
</Wrapper>
);
}

View File

@@ -0,0 +1,28 @@
import "@/styles/globals.css";
import "katex/dist/katex.min.css";
import { type Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
export const metadata: Metadata = {
title: "DeerFlow",
description: "A LangChain-based framework for building super agents.",
};
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await detectLocaleServer();
return (
<html lang={locale} suppressContentEditableWarning suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange>
<I18nProvider initialLocale={locale}>{children}</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,26 @@
export function GET() {
return Response.json({
mcp_servers: {
"mcp-github-trending": {
enabled: true,
type: "stdio",
command: "uvx",
args: ["mcp-github-trending"],
env: {},
url: null,
headers: {},
description:
"A MCP server that provides access to GitHub trending repositories and developers data",
},
"context-7": {
enabled: true,
description:
"Get the latest documentation and code into Cursor, Claude, or other LLMs",
},
"feishu-importer": {
enabled: true,
description: "Import Feishu documents",
},
},
});
}

View File

@@ -0,0 +1,34 @@
export function GET() {
return Response.json({
models: [
{
id: "doubao-seed-1.8",
name: "doubao-seed-1.8",
model: "doubao-seed-1-8",
display_name: "Doubao Seed 1.8",
supports_thinking: true,
},
{
id: "deepseek-v3.2",
name: "deepseek-v3.2",
model: "deepseek-chat",
display_name: "DeepSeek v3.2",
supports_thinking: true,
},
{
id: "gpt-5",
name: "gpt-5",
model: "gpt-5",
display_name: "GPT-5",
supports_thinking: true,
},
{
id: "gemini-3-pro",
name: "gemini-3-pro",
model: "gemini-3-pro",
display_name: "Gemini 3 Pro",
supports_thinking: true,
},
],
});
}

View File

@@ -0,0 +1,86 @@
export function GET() {
return Response.json({
skills: [
{
name: "deep-research",
description:
"Use this skill BEFORE any content generation task (PPT, design, articles, images, videos, reports). Provides a systematic methodology for conducting thorough, multi-angle web research to gather comprehensive information.",
license: null,
category: "public",
enabled: true,
},
{
name: "frontend-design",
description:
"Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.",
license: "Complete terms in LICENSE.txt",
category: "public",
enabled: true,
},
{
name: "github-deep-research",
description:
"Conduct multi-round deep research on any GitHub Repo. Use when users request comprehensive analysis, timeline reconstruction, competitive analysis, or in-depth investigation of GitHub. Produces structured markdown reports with executive summaries, chronological timelines, metrics analysis, and Mermaid diagrams. Triggers on Github repository URL or open source projects.",
license: null,
category: "public",
enabled: true,
},
{
name: "image-generation",
description:
"Use this skill when the user requests to generate, create, imagine, or visualize images including characters, scenes, products, or any visual content. Supports structured prompts and reference images for guided generation.",
license: null,
category: "public",
enabled: true,
},
{
name: "podcast-generation",
description:
"Use this skill when the user requests to generate, create, or produce podcasts from text content. Converts written content into a two-host conversational podcast audio format with natural dialogue.",
license: null,
category: "public",
enabled: true,
},
{
name: "ppt-generation",
description:
"Use this skill when the user requests to generate, create, or make presentations (PPT/PPTX). Creates visually rich slides by generating images for each slide and composing them into a PowerPoint file.",
license: null,
category: "public",
enabled: true,
},
{
name: "skill-creator",
description:
"Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.",
license: "Complete terms in LICENSE.txt",
category: "public",
enabled: true,
},
{
name: "vercel-deploy",
description:
'Deploy applications and websites to Vercel. Use this skill when the user requests deployment actions such as "Deploy my app", "Deploy this to production", "Create a preview deployment", "Deploy and give me the link", or "Push this live". No authentication required - returns preview URL and claimable deployment link.',
license: null,
category: "public",
enabled: true,
},
{
name: "video-generation",
description:
"Use this skill when the user requests to generate, create, or imagine videos. Supports structured prompts and reference image for guided generation.",
license: null,
category: "public",
enabled: true,
},
{
name: "web-design-guidelines",
description:
'Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".',
license: null,
category: "public",
enabled: true,
},
],
});
}

View File

@@ -0,0 +1,49 @@
import fs from "fs";
import path from "path";
import type { NextRequest } from "next/server";
export async function GET(
request: NextRequest,
{
params,
}: {
params: Promise<{
thread_id: string;
artifact_path?: string[] | undefined;
}>;
},
) {
const threadId = (await params).thread_id;
let artifactPath = (await params).artifact_path?.join("/") ?? "";
if (artifactPath.startsWith("mnt/")) {
artifactPath = path.resolve(
process.cwd(),
artifactPath.replace("mnt/", `public/demo/threads/${threadId}/`),
);
if (fs.existsSync(artifactPath)) {
if (request.nextUrl.searchParams.get("download") === "true") {
// Attach the file to the response
const headers = new Headers();
headers.set(
"Content-Disposition",
`attachment; filename="${artifactPath}"`,
);
return new Response(fs.readFileSync(artifactPath), {
status: 200,
headers,
});
}
if (artifactPath.endsWith(".mp4")) {
return new Response(fs.readFileSync(artifactPath), {
status: 200,
headers: {
"Content-Type": "video/mp4",
},
});
}
return new Response(fs.readFileSync(artifactPath), { status: 200 });
}
}
return new Response("File not found", { status: 404 });
}

View File

@@ -0,0 +1,20 @@
import fs from "fs";
import path from "path";
import type { NextRequest } from "next/server";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ thread_id: string }> },
) {
const threadId = (await params).thread_id;
const jsonString = fs.readFileSync(
path.resolve(process.cwd(), `public/demo/threads/${threadId}/thread.json`),
"utf8",
);
const json = JSON.parse(jsonString);
if (Array.isArray(json.history)) {
return Response.json(json);
}
return Response.json([json]);
}

View File

@@ -0,0 +1,85 @@
import fs from "fs";
import path from "path";
type ThreadSearchRequest = {
limit?: number;
offset?: number;
sortBy?: "updated_at" | "created_at";
sortOrder?: "asc" | "desc";
};
type MockThreadSearchResult = Record<string, unknown> & {
thread_id: string;
updated_at: string | undefined;
};
export async function POST(request: Request) {
const body = ((await request.json().catch(() => ({}))) ??
{}) as ThreadSearchRequest;
const rawLimit = body.limit;
let limit = 50;
if (typeof rawLimit === "number") {
const normalizedLimit = Math.max(0, Math.floor(rawLimit));
if (!Number.isNaN(normalizedLimit)) {
limit = normalizedLimit;
}
}
const rawOffset = body.offset;
let offset = 0;
if (typeof rawOffset === "number") {
const normalizedOffset = Math.max(0, Math.floor(rawOffset));
if (!Number.isNaN(normalizedOffset)) {
offset = normalizedOffset;
}
}
const sortBy = body.sortBy ?? "updated_at";
const sortOrder = body.sortOrder ?? "desc";
const threadsDir = fs.readdirSync(
path.resolve(process.cwd(), "public/demo/threads"),
{
withFileTypes: true,
},
);
const threadData = threadsDir
.map<MockThreadSearchResult | null>((threadId) => {
if (threadId.isDirectory() && !threadId.name.startsWith(".")) {
const threadData = JSON.parse(
fs.readFileSync(
path.resolve(`public/demo/threads/${threadId.name}/thread.json`),
"utf8",
),
) as Record<string, unknown>;
return {
...threadData,
thread_id: threadId.name,
updated_at:
typeof threadData.updated_at === "string"
? threadData.updated_at
: typeof threadData.created_at === "string"
? threadData.created_at
: undefined,
};
}
return null;
})
.filter((thread): thread is MockThreadSearchResult => thread !== null)
.sort((a, b) => {
const aTimestamp = a[sortBy];
const bTimestamp = b[sortBy];
const aParsed =
typeof aTimestamp === "string" ? Date.parse(aTimestamp) : 0;
const bParsed =
typeof bTimestamp === "string" ? Date.parse(bTimestamp) : 0;
const aValue = Number.isNaN(aParsed) ? 0 : aParsed;
const bValue = Number.isNaN(bParsed) ? 0 : bParsed;
return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
});
const pagedThreads = threadData.slice(offset, offset + limit);
return Response.json(pagedThreads);
}

View File

@@ -0,0 +1,25 @@
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { Hero } from "@/components/landing/hero";
import { CaseStudySection } from "@/components/landing/sections/case-study-section";
import { CommunitySection } from "@/components/landing/sections/community-section";
import { SandboxSection } from "@/components/landing/sections/sandbox-section";
import { SkillsSection } from "@/components/landing/sections/skills-section";
import { WhatsNewSection } from "@/components/landing/sections/whats-new-section";
export default function LandingPage() {
return (
<div className="min-h-screen w-full bg-[#0a0a0a]">
<Header />
<main className="flex w-full flex-col">
<Hero />
<CaseStudySection />
<SkillsSection />
<SandboxSection />
<WhatsNewSection />
<CommunitySection />
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context";
export default function AgentChatLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
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,
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";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter();
const { agent_name } = useParams<{
agent_name: string;
}>();
const { agent } = useAgent(agent_name);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: { ...settings.context, agent_name: agent_name },
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(
null,
"",
`/workspace/agents/${agent_name}/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message, { agent_name });
},
[sendMessage, threadId, agent_name],
);
const handleStop = useCallback(async () => {
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}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_name}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator messages={thread.messages} />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
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">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</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 && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{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}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}

View File

@@ -0,0 +1,422 @@
"use client";
import {
ArrowLeftIcon,
BotIcon,
CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
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,
createAgent,
getAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
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;
}
function getCreateAgentErrorMessage(
error: unknown,
networkErrorMessage: string,
fallbackMessage: string,
) {
if (error instanceof TypeError && error.message === "Failed to fetch") {
return networkErrorMessage;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [agentName, setAgentName] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
const threadId = useMemo(() => uuid(), []);
const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined,
context: {
mode: "flash",
is_bootstrap: true,
},
onFinish() {
if (!agent && setupAgentStatus === "requested") {
setSetupAgentStatus("idle");
}
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentName) return;
setSetupAgentStatus("completed");
void getAgentWithRetry(agentName).then((fetched) => {
if (fetched) {
setAgent(fetched);
return;
}
toast.error(t.agents.agentCreatedPendingRefresh);
});
},
});
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();
if (!trimmed) return;
if (!NAME_RE.test(trimmed)) {
setNameError(t.agents.nameStepInvalidError);
return;
}
setNameError("");
setIsCheckingName(true);
try {
const result = await checkAgentName(trimmed);
if (!result.available) {
setNameError(t.agents.nameStepAlreadyExistsError);
return;
}
} catch (err) {
if (
err instanceof AgentNameCheckError &&
err.reason === "backend_unreachable"
) {
setNameError(t.agents.nameStepNetworkError);
} else {
setNameError(t.agents.nameStepCheckError);
}
return;
} finally {
setIsCheckingName(false);
}
setIsCreatingAgent(true);
try {
await createAgent({
name: trimmed,
description: "",
soul: "",
});
} catch (err) {
setNameError(
getCreateAgentErrorMessage(
err,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
return;
} finally {
setIsCreatingAgent(false);
}
setAgentName(trimmed);
setStep("chat");
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed),
files: [],
});
}, [
nameInput,
sendMessage,
t.agents.nameStepAlreadyExistsError,
t.agents.nameStepNetworkError,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepCheckError,
t.agents.nameStepInvalidError,
threadId,
]);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleConfirmName();
}
};
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_name: agentName },
);
},
[agentName, sendMessage, thread.isLoading, threadId],
);
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 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>
);
if (step === "name") {
return (
<div className="flex size-full flex-col">
{header}
<main className="flex flex-1 flex-col items-center justify-center px-4">
<div className="w-full max-w-sm space-y-8">
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
<div className="space-y-3">
<Input
autoFocus
placeholder={t.agents.nameStepPlaceholder}
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value);
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError ? (
<p className="text-destructive text-sm">{nameError}</p>
) : null}
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={
!nameInput.trim() || isCheckingName || isCreatingAgent
}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
);
}
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
{header}
<main className="flex min-h-0 flex-1 flex-col">
{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={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
threadId={threadId}
thread={thread}
/>
</div>
<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 ? (
<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>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentName}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
</div>
</div>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}

View File

@@ -0,0 +1,5 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}

View File

@@ -0,0 +1,19 @@
"use client";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context";
export default function ChatLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
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";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const { threadId, setThreadId, 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({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
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}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator messages={thread.messages} />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
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">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{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}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() {
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
const filteredThreads = useMemo(() => {
return threads?.filter((thread) => {
return titleOfThread(thread).toLowerCase().includes(search.toLowerCase());
});
}, [threads, search]);
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { Toaster } from "sonner";
import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
function parseSidebarOpenCookie(
value: string | undefined,
): boolean | undefined {
if (value === "true") return true;
if (value === "false") return false;
return undefined;
}
export default async function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const cookieStore = await cookies();
const initialSidebarOpen = parseSidebarOpenCookie(
cookieStore.get("sidebar_state")?.value,
);
return (
<QueryClientProvider>
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider>
<CommandPalette />
<Toaster position="top-center" />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,20 @@
import fs from "fs";
import path from "path";
import { redirect } from "next/navigation";
import { env } from "@/env";
export default function WorkspacePage() {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
const firstThread = fs
.readdirSync(path.resolve(process.cwd(), "public/demo/threads"), {
withFileTypes: true,
})
.find((thread) => thread.isDirectory() && !thread.name.startsWith("."));
if (firstThread) {
return redirect(`/workspace/chats/${firstThread.name}`);
}
}
return redirect("/workspace/chats/new");
}