feat: support settings

This commit is contained in:
Henry Li
2026-01-20 23:43:21 +08:00
parent 3191a3845f
commit 10d253f461
25 changed files with 1355 additions and 217 deletions

View File

@@ -0,0 +1,5 @@
"use client";
export function AcknowledgePage() {
return null;
}

View File

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

View File

@@ -0,0 +1 @@
export { SettingsDialog } from "./settings-dialog";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} />
</>
);
}