refactor: extract components folder

This commit is contained in:
Li Xin
2025-05-02 10:43:14 +08:00
parent 18d896d15d
commit fdfc607747
44 changed files with 44 additions and 44 deletions

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import { queryMCPServerMetadata } from "~/core/api";
import {
MCPConfigSchema,
type MCPServerMetadata,
type SimpleMCPServerMetadata,
type SimpleSSEMCPServerMetadata,
type SimpleStdioMCPServerMetadata,
} from "~/core/mcp";
export function AddMCPServerDialog({
onAdd,
}: {
onAdd?: (servers: MCPServerMetadata[]) => void;
}) {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [validationError, setValidationError] = useState<string | null>("");
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleChange = useCallback((value: string) => {
setInput(value);
if (!value.trim()) {
setValidationError(null);
return;
}
setValidationError(null);
try {
const parsed = JSON.parse(value);
if (!("mcpServers" in parsed)) {
setValidationError("Missing `mcpServers` in JSON");
return;
}
} catch {
setValidationError("Invalid JSON");
return;
}
const result = MCPConfigSchema.safeParse(JSON.parse(value));
if (!result.success) {
if (result.error.errors[0]) {
const error = result.error.errors[0];
if (error.code === "invalid_union") {
if (error.unionErrors[0]?.errors[0]) {
setValidationError(error.unionErrors[0].errors[0].message);
return;
}
}
}
const errorMessage =
result.error.errors[0]?.message ?? "Validation failed";
setValidationError(errorMessage);
return;
}
const keys = Object.keys(result.data.mcpServers);
if (keys.length === 0) {
setValidationError("Missing server name in `mcpServers`");
return;
}
}, []);
const handleAdd = useCallback(async () => {
const config = MCPConfigSchema.parse(JSON.parse(input));
setInput(JSON.stringify(config, null, 2));
const addingServers: SimpleMCPServerMetadata[] = [];
for (const [key, server] of Object.entries(config.mcpServers)) {
if ("command" in server) {
const metadata: SimpleStdioMCPServerMetadata = {
transport: "stdio",
name: key,
command: server.command,
args: server.args,
env: server.env,
};
addingServers.push(metadata);
} else if ("url" in server) {
const metadata: SimpleSSEMCPServerMetadata = {
transport: "sse",
name: key,
url: server.url,
};
addingServers.push(metadata);
}
}
setProcessing(true);
const results: MCPServerMetadata[] = [];
let processingServer: string | null = null;
try {
setError(null);
for (const server of addingServers) {
processingServer = server.name;
const metadata = await queryMCPServerMetadata(server);
results.push({ ...metadata, name: server.name, enabled: true });
}
if (results.length > 0) {
onAdd?.(results);
}
setInput("");
setOpen(false);
} catch (e) {
console.error(e);
setError(`Failed to add server: ${processingServer}`);
} finally {
setProcessing(false);
}
}, [input, onAdd]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">Add Servers</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add New MCP Servers</DialogTitle>
</DialogHeader>
<DialogDescription>
DeerFlow uses the standard JSON MCP config to create a new server.
<br />
Paste your config below and click &quot;Add&quot; to add new servers.
</DialogDescription>
<main>
<Textarea
className="h-[360px]"
placeholder={
'Example:\n\n{\n "mcpServers": {\n "My Server": {\n "command": "python",\n "args": [\n "-m", "mcp_server"\n ],\n "env": {\n "API_KEY": "YOUR_API_KEY"\n }\n }\n }\n}'
}
value={input}
onChange={(e) => handleChange(e.target.value)}
/>
</main>
<DialogFooter>
<div className="flex h-10 w-full items-center justify-between gap-2">
<div className="text-destructive flex-grow overflow-hidden text-sm">
{validationError ?? error}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
className="w-24"
type="submit"
disabled={!input.trim() || !!validationError || processing}
onClick={handleAdd}
>
{processing && <Loader2 className="animate-spin" />}
Add
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Settings } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import {
type SettingsState,
changeSettings,
saveSettings,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { SETTINGS_TABS } from "../tabs";
export function SettingsDialog() {
const { isReplay } = useReplay();
const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
const [open, setOpen] = useState(false);
const [settings, setSettings] = useState(useSettingsStore.getState());
const [changes, setChanges] = useState<Partial<SettingsState>>({});
const handleTabChange = useCallback(
(newChanges: Partial<SettingsState>) => {
setTimeout(() => {
if (open) {
setChanges((prev) => ({
...prev,
...newChanges,
}));
}
}, 0);
},
[open],
);
const handleSave = useCallback(() => {
if (Object.keys(changes).length > 0) {
const newSettings: SettingsState = {
...settings,
...changes,
};
setSettings(newSettings);
setChanges({});
changeSettings(newSettings);
saveSettings();
}
setOpen(false);
}, [settings, changes]);
const handleOpen = useCallback(() => {
setSettings(useSettingsStore.getState());
}, []);
const handleClose = useCallback(() => {
setChanges({});
}, []);
useEffect(() => {
if (open) {
handleOpen();
} else {
handleClose();
}
}, [open, handleOpen, handleClose]);
const mergedSettings = useMemo<SettingsState>(() => {
return {
...settings,
...changes,
};
}, [settings, changes]);
if (isReplay) {
return null;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip title="Settings">
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<Settings />
</Button>
</DialogTrigger>
</Tooltip>
<DialogContent className="sm:max-w-[850px]">
<DialogHeader>
<DialogTitle>DeerFlow Settings</DialogTitle>
<DialogDescription>
Manage your DeerFlow settings here.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTabId}>
<div className="flex h-120 w-full overflow-auto border-y">
<ul className="flex w-50 shrink-0 border-r p-1">
<div className="size-full">
{SETTINGS_TABS.map((tab) => (
<li
key={tab.id}
className={cn(
"hover:accent-foreground hover:bg-accent mb-1 flex h-8 w-full cursor-pointer items-center gap-1.5 rounded px-2",
activeTabId === tab.id &&
"!bg-primary !text-primary-foreground",
)}
onClick={() => setActiveTabId(tab.id)}
>
<tab.icon size={16} />
<span>{tab.label}</span>
{tab.badge && (
<Badge
variant="outline"
className={cn(
"border-muted-foreground text-muted-foreground ml-auto px-1 py-0 text-xs",
activeTabId === tab.id &&
"border-primary-foreground text-primary-foreground",
)}
>
{tab.badge}
</Badge>
)}
</li>
))}
</div>
</ul>
<div className="min-w-0 flex-grow">
<div
id="settings-content-scrollable"
className="size-full overflow-auto p-4"
>
{SETTINGS_TABS.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<tab.component
settings={mergedSettings}
onChange={handleTabChange}
/>
</TabsContent>
))}
</div>
</div>
</div>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button className="w-24" type="submit" onClick={handleSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { BadgeInfo } from "lucide-react";
import { Markdown } from "~/components/deer-flow/markdown";
import about from "./about.md";
import type { Tab } from "./types";
export const AboutTab: Tab = () => {
return <Markdown>{about}</Markdown>;
};
AboutTab.icon = BadgeInfo;

View File

@@ -0,0 +1,45 @@
# 🦌 [About DeerFlow](https://github.com/bytedance/deer-flow)
> **From Open Source, Back to Open Source**
**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven AI automation framework inspired by the remarkable contributions of the open source community. Our mission is to seamlessly integrate language models with specialized tools for tasks such as web search, crawling, and Python code execution—all while giving back to the community that made this innovation possible.
---
## 🌟 GitHub Repository
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
---
## 📜 License
DeerFlow is proudly open source and distributed under the **MIT License**.
---
## 🙌 Acknowledgments
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[Zustand](https://zustand.docs.pmnd.rs/)**: A stunning state management library.
- **[Framer Motion](https://www.framer.com/motion/)**: An amazing animation library.
- **[React Markdown](https://www.npmjs.com/package/react-markdown)**: Exceptional markdown rendering with customizability.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of `DeerFlow`:
- **[DanielWalnut](https://github.com/hetaoBackend/)**
- **[Henry Li](https://github.com/magiccube/)**
Without their vision, passion and dedication, `DeerFlow` would not be what it is today.

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import type { SettingsState } from "~/core/store";
import type { Tab } from "./types";
const generalFormSchema = z.object({
autoAcceptedPlan: z.boolean(),
enableBackgroundInvestigation: z.boolean(),
maxPlanIterations: z.number().min(1, {
message: "Max plan iterations must be at least 1.",
}),
maxStepNum: z.number().min(1, {
message: "Max step number must be at least 1.",
}),
});
export const GeneralTab: Tab = ({
settings,
onChange,
}: {
settings: SettingsState;
onChange: (changes: Partial<SettingsState>) => void;
}) => {
const generalSettings = useMemo(() => settings.general, [settings]);
const form = useForm<z.infer<typeof generalFormSchema>>({
resolver: zodResolver(generalFormSchema, undefined, undefined),
values: generalSettings,
});
const currentSettings = form.watch();
useEffect(() => {
let hasChanges = false;
for (const key in currentSettings) {
if (
currentSettings[key as keyof typeof currentSettings] !==
settings.general[key as keyof SettingsState["general"]]
) {
hasChanges = true;
break;
}
}
if (hasChanges) {
onChange({ general: currentSettings });
}
}, [currentSettings, onChange, settings]);
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="text-lg font-medium">General</h1>
</header>
<main>
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="autoAcceptedPlan"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
id="autoAcceptedPlan"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm" htmlFor="autoAcceptedPlan">
Allow automatic acceptance of plans
</Label>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxPlanIterations"
render={({ field }) => (
<FormItem>
<FormLabel>Max plan iterations</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
{...field}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value))
}
/>
</FormControl>
<FormDescription>
Set to 1 for single-step planning. Set to 2 or more to
enable re-planning.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxStepNum"
render={({ field }) => (
<FormItem>
<FormLabel>Max steps of a research plan</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
{...field}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value))
}
/>
</FormControl>
<FormDescription>
By default, each research plan has 3 steps.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</main>
</div>
);
};
GeneralTab.displayName = "";
GeneralTab.icon = Settings;

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Settings, type LucideIcon } from "lucide-react";
import { AboutTab } from "./about-tab";
import { GeneralTab } from "./general-tab";
import { MCPTab } from "./mcp-tab";
export const SETTINGS_TABS = [GeneralTab, MCPTab, AboutTab].map((tab) => {
const name = tab.name ?? tab.displayName;
return {
...tab,
id: name.replace(/Tab$/, "").toLocaleLowerCase(),
label: name.replace(/Tab$/, ""),
icon: (tab.icon ?? <Settings />) as LucideIcon,
component: tab,
};
});

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { Blocks, PencilRuler, Trash } from "lucide-react";
import { useCallback, useState } from "react";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import type { MCPServerMetadata } from "~/core/mcp";
import { cn } from "~/lib/utils";
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
import type { Tab } from "./types";
export const MCPTab: Tab = ({ settings, onChange }) => {
const [servers, setServers] = useState<MCPServerMetadata[]>(
settings.mcp.servers,
);
const [newlyAdded, setNewlyAdded] = useState(false);
const handleAddServers = useCallback(
(servers: MCPServerMetadata[]) => {
const merged = mergeServers(settings.mcp.servers, servers);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
setNewlyAdded(true);
setTimeout(() => {
setNewlyAdded(false);
}, 1000);
setTimeout(() => {
document.getElementById("settings-content-scrollable")?.scrollTo({
top: 0,
behavior: "smooth",
});
}, 100);
},
[onChange, settings],
);
const handleDeleteServer = useCallback(
(name: string) => {
const merged = settings.mcp.servers.filter(
(server) => server.name !== name,
);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
},
[onChange, settings],
);
const handleToggleServer = useCallback(
(name: string, enabled: boolean) => {
const merged = settings.mcp.servers.map((server) =>
server.name === name ? { ...server, enabled } : server,
);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
},
[onChange, settings],
);
const animationProps = {
initial: { backgroundColor: "gray" },
animate: { backgroundColor: "transparent" },
transition: { duration: 1 },
style: {
transition: "background-color 1s ease-out",
},
};
return (
<div className="flex flex-col gap-4">
<header>
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-medium">MCP Servers</h1>
<AddMCPServerDialog onAdd={handleAddServers} />
</div>
<div className="text-muted-foreground markdown text-sm">
The Model Context Protocol boosts DeerFlow by integrating external
tools for tasks like private domain searches, web browsing, food
ordering, and more. Click here to
<a
className="ml-1"
target="_blank"
href="https://modelcontextprotocol.io/"
>
learn more about MCP.
</a>
</div>
</header>
<main>
<ul id="mcp-servers-list" className="flex flex-col gap-4">
{servers.map((server) => {
const isNew =
server.createdAt &&
server.createdAt > Date.now() - 1000 * 60 * 60 * 1;
return (
<motion.li
className={
"!bg-card group relative overflow-hidden rounded-lg border pb-2 shadow duration-300"
}
key={server.name}
{...(isNew && newlyAdded && animationProps)}
>
<div className="absolute top-3 right-2">
<Tooltip title="Enable/disable server">
<div className="flex items-center gap-2">
<Switch
id="airplane-mode"
checked={server.enabled}
onCheckedChange={(checked) => {
handleToggleServer(server.name, checked);
}}
/>
</div>
</Tooltip>
</div>
<div className="absolute top-1 right-12 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip title="Delete server">
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteServer(server.name)}
>
<Trash />
</Button>
</Tooltip>
</div>
<div
className={cn(
"flex flex-col items-start px-4 py-2",
!server.enabled && "text-muted-foreground",
)}
>
<div
className={cn(
"mb-2 flex items-center gap-2",
!server.enabled && "opacity-70",
)}
>
<div className="text-lg font-medium">{server.name}</div>
{!server.enabled && (
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
Disabled
</div>
)}
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
{server.transport}
</div>
{isNew && (
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
New
</div>
)}
</div>
<ul
className={cn(
"flex flex-wrap items-center gap-2",
!server.enabled && "opacity-70",
)}
>
<PencilRuler size={16} />
{server.tools.map((tool) => (
<li
key={tool.name}
className="text-muted-foreground border-muted-foreground w-fit rounded-md border px-2"
>
<Tooltip key={tool.name} title={tool.description}>
<div className="w-fit text-sm">{tool.name}</div>
</Tooltip>
</li>
))}
</ul>
</div>
</motion.li>
);
})}
</ul>
</main>
</div>
);
};
MCPTab.icon = Blocks;
MCPTab.badge = "Beta";
function mergeServers(
existing: MCPServerMetadata[],
added: MCPServerMetadata[],
): MCPServerMetadata[] {
const serverMap = new Map(existing.map((server) => [server.name, server]));
for (const addedServer of added) {
addedServer.createdAt = Date.now();
addedServer.updatedAt = Date.now();
serverMap.set(addedServer.name, addedServer);
}
const result = Array.from(serverMap.values());
result.sort((a, b) => b.createdAt - a.createdAt);
return result;
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { LucideIcon } from "lucide-react";
import type { FunctionComponent } from "react";
import type { SettingsState } from "~/core/store";
export type Tab = FunctionComponent<{
settings: SettingsState;
onChange: (changes: Partial<SettingsState>) => void;
}> & {
displayName?: string;
icon?: LucideIcon;
badge?: string;
};