mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-14 18:54:46 +08:00
feat: support settings
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
export function AcknowledgePage() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { enUS, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "en-US", label: enUS.locale.localName },
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
|
||||
export function AppearanceSettingsPage() {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "system",
|
||||
label: t.settings.appearance.system,
|
||||
description: t.settings.appearance.systemDescription,
|
||||
icon: MonitorSmartphoneIcon,
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: t.settings.appearance.light,
|
||||
description: t.settings.appearance.lightDescription,
|
||||
icon: SunIcon,
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: t.settings.appearance.dark,
|
||||
description: t.settings.appearance.darkDescription,
|
||||
icon: MoonIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.system,
|
||||
t.settings.appearance.systemDescription,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection
|
||||
title={t.settings.appearance.themeTitle}
|
||||
description={t.settings.appearance.themeDescription}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{themeOptions.map((option) => (
|
||||
<ThemePreviewCard
|
||||
key={option.id}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "system" | "light" | "dark"}
|
||||
resolvedTheme={resolvedTheme}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title={t.settings.appearance.languageTitle}
|
||||
description={t.settings.appearance.languageDescription}
|
||||
>
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) => changeLocale(value as Locale)}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
resolvedTheme,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "system" | "light" | "dark";
|
||||
resolvedTheme?: string;
|
||||
onSelect: (mode: "system" | "light" | "dark") => void;
|
||||
}) {
|
||||
const previewMode =
|
||||
mode === "system" ? (resolvedTheme === "dark" ? "dark" : "light") : mode;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(mode)}
|
||||
className={cn(
|
||||
"group flex h-full flex-col gap-3 rounded-lg border p-4 text-left transition-all",
|
||||
active
|
||||
? "border-primary ring-primary/30 shadow-sm ring-2"
|
||||
: "hover:border-border hover:shadow-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-muted rounded-md p-2">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm leading-none font-semibold">{label}</div>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
previewMode === "dark" ? "bg-emerald-400" : "bg-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<div className="h-2 w-10 rounded-full bg-current/20" />
|
||||
<div className="h-2 w-6 rounded-full bg-current/15" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_240px] gap-3 px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-3/4 rounded-full bg-current/15" />
|
||||
<div className="h-3 w-1/2 rounded-full bg-current/10" />
|
||||
<div className="h-[90px] rounded-md border border-current/10 bg-current/5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-md bg-current/10" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 w-14 rounded-full bg-current/15" />
|
||||
<div className="h-2 w-10 rounded-full bg-current/10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-dashed border-current/15 p-2">
|
||||
<div className="h-2 w-3/5 rounded-full bg-current/15" />
|
||||
<div className="h-2 w-2/5 rounded-full bg-current/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SettingsDialog } from "./settings-dialog";
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { PaletteIcon, SparklesIcon, WrenchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AcknowledgePage } from "@/components/workspace/settings/acknowledge-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection = "appearance" | "tools" | "skills" | "acknowledge";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
defaultSection?: SettingsSection;
|
||||
};
|
||||
|
||||
export function SettingsDialog({
|
||||
defaultSection = "appearance",
|
||||
...dialogProps
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "appearance",
|
||||
label: t.settings.sections.appearance,
|
||||
icon: PaletteIcon,
|
||||
},
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...dialogProps}>
|
||||
<DialogContent
|
||||
className="flex h-[75vh] max-h-[calc(100vh-2rem)] flex-col sm:max-w-5xl md:max-w-6xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle>{t.settings.title}</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.description}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[220px_1fr]">
|
||||
<nav className="bg-muted/30 min-h-0 overflow-y-auto rounded-lg border p-2">
|
||||
<ul className="space-y-1 pr-1">
|
||||
{sections.map(({ id, label, icon: Icon }) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection(id as SettingsSection)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<ScrollArea className="h-full min-h-0 rounded-lg border">
|
||||
<div className="space-y-8 p-6">
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "skills" && <SkillSettingsPage />}
|
||||
{activeSection === "acknowledge" && <AcknowledgePage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SettingsSection({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn(className)}>
|
||||
<header className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
<main className="mt-4">{children}</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemTitle,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function SkillSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { skills, isLoading, error } = useSkills();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
<SkillSettingsList skills={skills} />
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSettingsList({ skills }: { skills: Skill[] }) {
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparklesIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No skill yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Put your skill folders under the `/skills/custom` folder under the
|
||||
root folder of DeerFlow.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{skills.map((skill) => (
|
||||
<Item className="w-full" variant="outline" key={skill.name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{skill.name}</div>
|
||||
<Badge variant="outline">{skill.category}</Badge>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({ skillName: skill.name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
||||
import type { MCPServerConfig } from "@/core/mcp/types";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function ToolSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { config, isLoading, error } = useMCPConfig();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.tools.title}
|
||||
description={t.settings.tools.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
config && <MCPServerList servers={config.mcp_servers} />
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPServerList({
|
||||
servers,
|
||||
}: {
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
}) {
|
||||
const { mutate: enableMCPServer } = useEnableMCPServer();
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{Object.entries(servers).map(([name, config]) => (
|
||||
<Item className="w-full" variant="outline" key={name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{config.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
@@ -24,32 +26,42 @@ export function WorkspaceSidebar({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
const { t } = useI18n();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sidebar variant="sidebar" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavMenu />
|
||||
<RecentChatList />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarGroup className="px-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<div className="text-muted-foreground cursor-pointer">
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t.common.settings}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<>
|
||||
<Sidebar variant="sidebar" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavMenu />
|
||||
<RecentChatList />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarGroup className="px-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground flex w-full cursor-pointer items-center gap-2"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t.common.settings}</span>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user