mirror of
https://git.mirrors.martin98.com/https://github.com/bytedance/deer-flow
synced 2025-08-19 05:55:53 +08:00
feat: implement MCP UIs
This commit is contained in:
parent
d9ffb19950
commit
10b1d63834
@ -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
|
||||
|
@ -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?.();
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 </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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
175
web/src/app/_settings/dialogs/add-mcp-server-dialog.tsx
Normal file
175
web/src/app/_settings/dialogs/add-mcp-server-dialog.tsx
Normal 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 "Add" 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>
|
||||
);
|
||||
}
|
163
web/src/app/_settings/dialogs/settings-dialog.tsx
Normal file
163
web/src/app/_settings/dialogs/settings-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
web/src/app/_settings/tabs/about-tab.tsx
Normal file
14
web/src/app/_settings/tabs/about-tab.tsx
Normal 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;
|
127
web/src/app/_settings/tabs/general-tab.tsx
Normal file
127
web/src/app/_settings/tabs/general-tab.tsx
Normal 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;
|
19
web/src/app/_settings/tabs/index.tsx
Normal file
19
web/src/app/_settings/tabs/index.tsx
Normal 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,
|
||||
};
|
||||
});
|
152
web/src/app/_settings/tabs/mcp-tab.tsx
Normal file
152
web/src/app/_settings/tabs/mcp-tab.tsx
Normal 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;
|
||||
}
|
16
web/src/app/_settings/tabs/types.ts
Normal file
16
web/src/app/_settings/tabs/types.ts
Normal 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;
|
||||
};
|
@ -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);
|
||||
|
66
web/src/components/ui/accordion.tsx
Normal file
66
web/src/components/ui/accordion.tsx
Normal 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 }
|
46
web/src/components/ui/badge.tsx
Normal file
46
web/src/components/ui/badge.tsx
Normal 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 }
|
32
web/src/components/ui/checkbox.tsx
Normal file
32
web/src/components/ui/checkbox.tsx
Normal 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 }
|
33
web/src/components/ui/collapsible.tsx
Normal file
33
web/src/components/ui/collapsible.tsx
Normal 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 }
|
185
web/src/components/ui/select.tsx
Normal file
185
web/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
@ -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(
|
||||
|
@ -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
20
web/src/core/api/mcp.ts
Normal 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();
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
|
12
web/src/core/api/resolve-service-url.ts
Normal file
12
web/src/core/api/resolve-service-url.ts
Normal 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();
|
||||
}
|
6
web/src/core/mcp/index.ts
Normal file
6
web/src/core/mcp/index.ts
Normal 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";
|
57
web/src/core/mcp/schema.ts
Normal file
57
web/src/core/mcp/schema.ts
Normal 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
43
web/src/core/mcp/types.ts
Normal 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
16
web/src/core/mcp/utils.ts
Normal 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;
|
||||
}
|
@ -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>(() => ({
|
||||
|
@ -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,
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user