Files
deer-flow/web/src/app/_dialogs/settings-dialog.tsx
T
2025-04-23 21:18:47 +08:00

268 lines
7.4 KiB
TypeScript

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,
};
});