mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-26 15:24:48 +08:00
feat: implement MCP UIs
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user