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

@ -1,5 +1,21 @@
# MCP Integrations
## Example of MCP Server Configuration
```json
{
"mcpServers": {
"mcp-github-trending": {
"transport": "stdio",
"command": "uvx",
"args": [
"mcp-github-trending"
]
}
}
}
```
## APIs
### Get Information of MCP Server

View File

@ -30,7 +30,7 @@ export function InputBox({
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, feedback: { option: Option } | null) => void;
onSend?: (message: string, options?: { interruptFeedback?: string }) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
@ -63,7 +63,9 @@ export function InputBox({
return;
}
if (onSend) {
onSend(message, feedback ?? null);
onSend(message, {
interruptFeedback: feedback?.option.value,
});
setMessage("");
onRemoveFeedback?.();
}

View File

@ -17,7 +17,6 @@ import {
import type { Message, Option } from "~/core/messages";
import {
openResearch,
sendMessage,
useMessage,
useResearchTitle,
useStore,
@ -35,9 +34,14 @@ import { Tooltip } from "./tooltip";
export function MessageListView({
className,
onFeedback,
onSendMessage,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
@ -81,6 +85,7 @@ export function MessageListView({
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
@ -96,14 +101,19 @@ function MessageListItem({
className,
messageId,
waitForFeedback,
onFeedback,
interruptMessage,
onFeedback,
onSendMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
@ -126,6 +136,7 @@ function MessageListItem({
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
</div>
);
@ -269,11 +280,16 @@ function PlanCard({
interruptMessage,
onFeedback,
waitForFeedback,
onSendMessage,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
@ -284,13 +300,15 @@ function PlanCard({
return parseJSON(message.content ?? "", {});
}, [message.content]);
const handleAccept = useCallback(async () => {
await sendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}, []);
if (onSendMessage) {
onSendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}
}, [onSendMessage]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>

View File

@ -17,16 +17,15 @@ export function MessagesBlock({ className }: { className?: string }) {
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (message: string) => {
async (message: string, options?: { interruptFeedback?: string }) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
maxPlanIterations: 1,
maxStepNum: 3,
interruptFeedback: feedback?.option.value,
interruptFeedback:
options?.interruptFeedback ?? feedback?.option.value,
},
{
abortSignal: abortController.signal,
@ -37,6 +36,7 @@ export function MessagesBlock({ className }: { className?: string }) {
[feedback],
);
const handleCancel = useCallback(() => {
console.info("cancel");
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
@ -51,7 +51,11 @@ export function MessagesBlock({ className }: { className?: string }) {
}, [setFeedback]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView className="flex flex-grow" onFeedback={handleFeedback} />
<MessageListView
className="flex flex-grow"
onFeedback={handleFeedback}
onSendMessage={handleSend}
/>
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter

View File

@ -4,12 +4,19 @@
import { PythonOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { BookOpenText, Search } from "lucide-react";
import { BookOpenText, PencilRuler, Search } from "lucide-react";
import { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { Skeleton } from "~/components/ui/skeleton";
import { findMCPTool } from "~/core/mcp";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { parseJSON } from "~/core/utils";
@ -20,6 +27,7 @@ import Image from "./image";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
import { Tooltip } from "./tooltip";
export function ResearchActivitiesBlock({
className,
@ -85,6 +93,8 @@ function ActivityListItem({ messageId }: { messageId: string }) {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
} else {
return <MCPToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
@ -142,7 +152,7 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
className="flex items-center"
animated={searchResults === undefined}
>
<Search className={"mr-2"} />
<Search size={16} className={"mr-2"} />
<span>Searching for&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { query: string }).query}
@ -229,12 +239,12 @@ function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<div>
<RainbowText
className="flex items-center"
className="flex items-center text-base font-medium italic"
animated={toolCall.result === undefined}
>
<BookOpenText className={"mr-2"} />
<BookOpenText size={16} className={"mr-2"} />
<span>Reading</span>
</RainbowText>
</div>
@ -264,15 +274,22 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
}, [toolCall.args]);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<div className="flex items-center">
<PythonOutlined className={"mr-2"} />
<RainbowText animated={toolCall.result === undefined}>
<RainbowText
className="text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running Python code
</RainbowText>
</div>
<div className="px-5">
<div className="bg-accent mt-2 rounded-md p-2 text-sm">
<SyntaxHighlighter language="python" style={docco}>
<div className="bg-accent mt-2 max-h-[400px] w-[800px] overflow-y-auto rounded-md p-2 text-sm">
<SyntaxHighlighter
customStyle={{ background: "transparent" }}
language="python"
style={docco}
>
{code}
</SyntaxHighlighter>
</div>
@ -280,3 +297,43 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
</section>
);
}
function MCPToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const tool = useMemo(() => findMCPTool(toolCall.name), [toolCall.name]);
return (
<section className="mt-4 pl-4">
<div className="w-fit overflow-y-auto rounded-md py-0">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>
<Tooltip title={tool?.description}>
<div className="flex items-center font-medium italic">
<PencilRuler size={16} className={"mr-2"} />
<RainbowText
className="pr-0.5 text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running {toolCall.name ? toolCall.name + "()" : "MCP tool"}
</RainbowText>
</div>
</Tooltip>
</AccordionTrigger>
<AccordionContent>
{toolCall.result && (
<div className="bg-accent max-h-[400px] w-[800px] overflow-y-auto rounded-md text-sm">
<SyntaxHighlighter
customStyle={{ background: "transparent" }}
language="json"
style={docco}
>
{toolCall.result}
</SyntaxHighlighter>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</section>
);
}

View File

@ -17,7 +17,7 @@ export function Tooltip({
title?: React.ReactNode;
}) {
return (
<ShadcnTooltip>
<ShadcnTooltip delayDuration={750}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className={className}>{title}</TooltipContent>
</ShadcnTooltip>

View File

@ -1,267 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { BadgeInfo, Blocks, Settings, type LucideIcon } from "lucide-react";
import {
type FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Tabs, TabsContent } from "~/components/ui/tabs";
import {
type SettingsState,
changeSettings,
saveSettings,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { Markdown } from "../_components/markdown";
import { Tooltip } from "../_components/tooltip";
import about from "./about.md";
export function SettingsDialog() {
const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
const [open, setOpen] = useState(false);
const [settings, setSettings] = useState(useSettingsStore.getState());
const changes = useRef<Partial<SettingsState>>({});
const handleTabChange = useCallback((newChanges: Partial<SettingsState>) => {
changes.current = {
...changes.current,
...newChanges,
};
}, []);
const handleSave = useCallback(() => {
if (Object.keys(changes.current).length > 0) {
const newSettings: SettingsState = {
...settings,
...changes.current,
};
setSettings(newSettings);
changes.current = {};
changeSettings(newSettings);
saveSettings();
}
setOpen(false);
}, [settings, changes]);
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-[800px]">
<DialogHeader>
<DialogTitle>DeerFlow Settings</DialogTitle>
<DialogDescription>
Manage your DeerFlow settings here.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTabId}>
<div className="flex h-100 w-full overflow-auto border-y">
<ul className="flex w-60 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>
</li>
))}
</div>
</ul>
<div className="min-w-0 flex-grow">
<div className="size-full overflow-auto p-4">
{SETTINGS_TABS.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<tab.component
settings={settings}
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>
);
}
type Tab = FunctionComponent<{
settings: SettingsState;
onChange: (changes: Partial<SettingsState>) => void;
}> & {
displayName?: string;
icon?: LucideIcon;
};
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.",
}),
});
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),
defaultValues: 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 (
<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>
);
};
GeneralTab.displayName = "GeneralTab";
GeneralTab.icon = Settings;
const MCPTab: Tab = () => {
return (
<div className="text-muted-foreground">
<p>Coming soon...</p>
</div>
);
};
MCPTab.icon = Blocks;
const AboutTab: Tab = () => {
return <Markdown>{about}</Markdown>;
};
AboutTab.icon = BadgeInfo;
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,175 @@
// 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 New Server</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add New MCP Server</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 create a new
server.
</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,163 @@
// 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 { 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 {
type SettingsState,
changeSettings,
saveSettings,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { Tooltip } from "../../_components/tooltip";
import { SETTINGS_TABS } from "../tabs";
export function SettingsDialog() {
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]);
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-[800px]">
<DialogHeader>
<DialogTitle>DeerFlow Settings</DialogTitle>
<DialogDescription>
Manage your DeerFlow settings here.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTabId}>
<div className="flex h-100 w-full overflow-auto border-y">
<ul className="flex w-60 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 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 "~/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,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;
};

View File

@ -16,7 +16,7 @@ import { MessagesBlock } from "./_components/messages-block";
import { ResearchBlock } from "./_components/research-block";
import { ThemeToggle } from "./_components/theme-toggle";
import { Tooltip } from "./_components/tooltip";
import { SettingsDialog } from "./_dialogs/settings-dialog";
import { SettingsDialog } from "./_settings/dialogs/settings-dialog";
export default function HomePage() {
const openResearchId = useStore((state) => state.openResearchId);

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -1,11 +1,11 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
import type { MCPServerMetadata } from "../mcp";
import { fetchStream } from "../sse";
import { sleep } from "../utils";
import { resolveServiceURL } from "./resolve-service-url";
import type { ChatEvent } from "./types";
export function chatStream(
@ -15,23 +15,29 @@ export function chatStream(
max_plan_iterations: number;
max_step_num: number;
interrupt_feedback?: string;
mcp_settings?: {
servers: Record<
string,
MCPServerMetadata & {
enabled_tools: string[];
add_to_agents: string[];
}
>;
};
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock")) {
return chatStreamMock(userMessage, params, options);
}
return fetchStream<ChatEvent>(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") + "/chat/stream",
{
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
},
);
return fetchStream<ChatEvent>(resolveServiceURL("chat/stream"), {
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
});
}
async function* chatStreamMock(

View File

@ -2,4 +2,6 @@
// SPDX-License-Identifier: MIT
export * from "./chat";
export * from "./mcp";
export * from "./podcast";
export * from "./types";

20
web/src/core/api/mcp.ts Normal file
View File

@ -0,0 +1,20 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { SimpleMCPServerMetadata } from "../mcp";
import { resolveServiceURL } from "./resolve-service-url";
export async function queryMCPServerMetadata(config: SimpleMCPServerMetadata) {
const response = await fetch(resolveServiceURL("mcp/server/metadata"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

View File

@ -1,20 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
import { resolveServiceURL } from "./resolve-service-url";
export async function generatePodcast(content: string) {
const response = await fetch(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") +
"/podcast/generate",
{
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
const response = await fetch(resolveServiceURL("podcast/generate"), {
method: "post",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

View File

@ -0,0 +1,12 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
export function resolveServiceURL(path: string) {
let BASE_URL = env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/";
if (!BASE_URL.endsWith("/")) {
BASE_URL += "/";
}
return new URL(path, BASE_URL).toString();
}

View File

@ -0,0 +1,6 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export * from "./schema";
export * from "./types";
export * from "./utils";

View File

@ -0,0 +1,57 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { z } from "zod";
export const MCPConfigSchema = z.object({
mcpServers: z.record(
z.union(
[
z.object({
command: z.string({
message: "`command` must be a string",
}),
args: z
.array(z.string(), {
message: "`args` must be an array of strings",
})
.optional(),
env: z
.record(z.string(), {
message: "`env` must be an object of key-value pairs",
})
.optional(),
}),
z.object({
url: z
.string({
message:
"`url` must be a valid URL starting with http:// or https://",
})
.refine(
(value) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{
message:
"`url` must be a valid URL starting with http:// or https://",
},
),
env: z
.record(z.string(), {
message: "`env` must be an object of key-value pairs",
})
.optional(),
}),
],
{
message: "Invalid server type",
},
),
),
});

43
web/src/core/mcp/types.ts Normal file
View File

@ -0,0 +1,43 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export interface MCPToolMetadata {
name: string;
description: string;
inputSchema?: Record<string, unknown>;
}
export interface GenericMCPServerMetadata<T extends string> {
name: string;
transport: T;
enabled: boolean;
env?: Record<string, string>;
tools: MCPToolMetadata[];
createdAt: number;
updatedAt: number;
}
export interface StdioMCPServerMetadata
extends GenericMCPServerMetadata<"stdio"> {
transport: "stdio";
command: string;
args?: string[];
}
export type SimpleStdioMCPServerMetadata = Omit<
StdioMCPServerMetadata,
"enabled" | "tools" | "createdAt" | "updatedAt"
>;
export interface SSEMCPServerMetadata extends GenericMCPServerMetadata<"sse"> {
transport: "sse";
url: string;
}
export type SimpleSSEMCPServerMetadata = Omit<
SSEMCPServerMetadata,
"enabled" | "tools" | "createdAt" | "updatedAt"
>;
export type MCPServerMetadata = StdioMCPServerMetadata | SSEMCPServerMetadata;
export type SimpleMCPServerMetadata =
| SimpleStdioMCPServerMetadata
| SimpleSSEMCPServerMetadata;

16
web/src/core/mcp/utils.ts Normal file
View File

@ -0,0 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useSettingsStore } from "../store";
export function findMCPTool(name: string) {
const mcpServers = useSettingsStore.getState().mcp.servers;
for (const server of mcpServers) {
for (const tool of server.tools) {
if (tool.name === name) {
return tool;
}
}
}
return null;
}

View File

@ -1,5 +1,10 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { create } from "zustand";
import type { MCPServerMetadata } from "../mcp";
const SETTINGS_KEY = "deerflow.settings";
const DEFAULT_SETTINGS: SettingsState = {
@ -7,6 +12,34 @@ const DEFAULT_SETTINGS: SettingsState = {
maxPlanIterations: 1,
maxStepNum: 3,
},
mcp: {
servers: [
{
enabled: true,
name: "Zapier",
transport: "sse",
url: "https://actions.zapier.com/mcp/sk-ak-OnJ4kVKzxcLjpvpLChkT7RCYuh/sse",
env: { API_KEY: "123" },
createdAt: new Date("2025-04-20").valueOf(),
updatedAt: new Date("2025-04-20").valueOf(),
tools: [
{
name: "youtube_get_report",
description:
"Creates a report on specified data from your owned and managed channels.",
},
{
name: "edit_actions",
description: "Edit your existing MCP provider actions",
},
{
name: "add_actions",
description: "Add new actions to your MCP provider",
},
],
},
],
},
};
export type SettingsState = {
@ -14,6 +47,9 @@ export type SettingsState = {
maxPlanIterations: number;
maxStepNum: number;
};
mcp: {
servers: MCPServerMetadata[];
};
};
export const useSettingsStore = create<SettingsState>(() => ({

View File

@ -4,13 +4,13 @@
import { nanoid } from "nanoid";
import { create } from "zustand";
import { chatStream } from "../api";
import { generatePodcast } from "../api/podcast";
import { chatStream, generatePodcast } from "../api";
import type { Message } from "../messages";
import { mergeMessage } from "../messages";
import { parseJSON } from "../utils";
import { useSettingsStore } from "./settings-store";
import type { MCPServerMetadata, SimpleMCPServerMetadata } from "../mcp";
const THREAD_ID = nanoid();
@ -57,7 +57,52 @@ export async function sendMessage(
setResponding(true);
try {
const generalSettings = useSettingsStore.getState().general;
const settings = useSettingsStore.getState();
const generalSettings = settings.general;
const mcpServers = settings.mcp.servers.filter((server) => server.enabled);
let mcpSettings:
| {
servers: Record<
string,
MCPServerMetadata & {
enabled_tools: string[];
add_to_agents: string[];
}
>;
}
| undefined = undefined;
if (mcpServers.length > 0) {
mcpSettings = {
servers: mcpServers.reduce((acc, cur) => {
const { transport, env } = cur;
let server: SimpleMCPServerMetadata;
if (transport === "stdio") {
server = {
name: cur.name,
transport,
env,
command: cur.command,
args: cur.args,
};
} else {
server = {
name: cur.name,
transport,
env,
url: cur.url,
};
}
return {
...acc,
[cur.name]: {
...server,
enabled_tools: cur.tools.map((tool) => tool.name),
add_to_agents: ["researcher"],
},
};
}, {}),
};
}
const stream = chatStream(
content,
{
@ -65,6 +110,7 @@ export async function sendMessage(
max_plan_iterations: generalSettings.maxPlanIterations,
max_step_num: generalSettings.maxStepNum,
interrupt_feedback: interruptFeedback,
mcp_settings: mcpSettings,
},
options,
);