feat: implement MCP UIs

This commit is contained in:
Li Xin
2025-04-24 15:41:33 +08:00
parent d9ffb19950
commit 10b1d63834
32 changed files with 1419 additions and 321 deletions

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 "~/app/_components/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,39 @@
# 🦌 [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.
### Special Thanks
- **[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.

View File

@@ -0,0 +1,127 @@
// 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 type { SettingsState } from "~/core/store";
import type { Tab } from "./types";
const generalFormSchema = z.object({
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="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 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,152 @@
// 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 "~/app/_components/tooltip";
import { Button } from "~/components/ui/button";
import type { MCPServerMetadata } from "~/core/mcp";
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 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 shadow"
key={server.name}
{...(isNew && newlyAdded && animationProps)}
>
<div className="absolute top-1 right-2 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="flex flex-col items-start px-4 py-2">
<div className="mb-2 flex items-center gap-2">
<div className="text-lg font-medium">{server.name}</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="flex flex-wrap items-center gap-2">
<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;
};