feat: add notification

This commit is contained in:
Henry Li
2026-01-31 11:08:27 +08:00
parent 4e0571f3b3
commit c62caf95c4
18 changed files with 482 additions and 56 deletions

View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -4,14 +4,12 @@ import {
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -156,6 +154,15 @@ export function ArtifactFileDetail({
</div>
<div className="flex items-center gap-2">
<ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={PackageIcon}
label={t.common.install}
tooltip={t.common.openInNewWindow}
/>
</a>
)}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
@@ -241,7 +248,7 @@ export function ArtifactFilePreview({
content: string;
language: string;
}) {
const { citations, cleanContent, citationMap } = React.useMemo(() => {
const { cleanContent, citationMap } = React.useMemo(() => {
const parsed = parseCitations(content ?? "");
const map = buildCitationMap(parsed.citations);
return {

View File

@@ -1,4 +1,4 @@
import { DownloadIcon } from "lucide-react";
import { DownloadIcon, PackageIcon } from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
@@ -48,6 +48,22 @@ export function ArtifactFileList({
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction>
{file.endsWith(".skill") && (
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost">
<PackageIcon className="size-4" />
{t.common.install}
</Button>
</a>
)}
<a
href={urlOfArtifact({
filepath: file,

View File

@@ -0,0 +1,92 @@
"use client";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { SettingsSection } from "./settings-section";
export function NotificationSettingsPage() {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = async (enabled: boolean) => {
setSettings("notification", {
enabled,
});
};
if (!isSupported) {
return (
<SettingsSection
title={t.settings.notification.title}
description={t.settings.notification.description}
>
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
</SettingsSection>
);
}
return (
<SettingsSection
title={t.settings.notification.title}
description={
<div className="flex items-center gap-2">
<div>{t.settings.notification.description}</div>
<div>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<div className="flex flex-col gap-4">
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
</div>
)}
</div>
</SettingsSection>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { PaletteIcon, SparklesIcon, WrenchIcon } from "lucide-react";
import { BellIcon, PaletteIcon, SparklesIcon, WrenchIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
@@ -12,12 +12,18 @@ import {
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 { NotificationSettingsPage } from "@/components/workspace/settings/notification-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 SettingsSection =
| "appearance"
| "tools"
| "skills"
| "notification"
| "acknowledge";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -38,6 +44,11 @@ export function SettingsDialog({
label: t.settings.sections.appearance,
icon: PaletteIcon,
},
{
id: "notification",
label: t.settings.sections.notification,
icon: BellIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
],
@@ -45,6 +56,7 @@ export function SettingsDialog({
t.settings.sections.appearance,
t.settings.sections.tools,
t.settings.sections.skills,
t.settings.sections.notification,
],
);
return (
@@ -89,6 +101,7 @@ export function SettingsDialog({
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "acknowledge" && <AcknowledgePage />}
</div>
</ScrollArea>

View File

@@ -14,9 +14,9 @@ export function SettingsSection({
return (
<section className={cn(className)}>
<header className="space-y-2">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="text-lg font-semibold">{title}</div>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
<div className="text-muted-foreground text-sm">{description}</div>
)}
</header>
<main className="mt-4">{children}</main>

View File

@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
@@ -19,6 +20,7 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/core/i18n/hooks";
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
@@ -47,61 +49,34 @@ export function SkillSettingsPage() {
function SkillSettingsList({ skills }: { skills: Skill[] }) {
const { t } = useI18n();
const [filter, setFilter] = useState<"public" | "custom">("public");
const [filter, setFilter] = useState<string>("public");
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
if (skills.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No agent skill yet</EmptyTitle>
<EmptyDescription>
Put your agent skill folders under the `/skills/custom` folder under
the root folder of DeerFlow.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
const handleCreateSkill = () => {
console.log("create skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex gap-2">
<Button
className="rounded-xl"
size="sm"
variant={filter === "public" ? "default" : "outline"}
onClick={() => setFilter("public")}
>
{t.common.public}
</Button>
<Button
className="rounded-xl"
size="sm"
variant={filter === "custom" ? "default" : "outline"}
onClick={() => setFilter("custom")}
>
{t.common.custom}
</Button>
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<Button variant="outline" size="sm" onClick={handleCreateSkill}>
Create Skill
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No skill yet</EmptyTitle>
<EmptyDescription>
Put your skill folders under the `skills/{filter}` folder under
the root folder of DeerFlow.
</EmptyDescription>
</EmptyHeader>
</Empty>
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
@@ -128,3 +103,23 @@ function SkillSettingsList({ skills }: { skills: Skill[] }) {
</div>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No agent skill yet</EmptyTitle>
<EmptyDescription>
Put your agent skill folders under the `/skills/custom` folder under
the root folder of DeerFlow.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={onCreateSkill}>Create Your First Skill</Button>
</EmptyContent>
</Empty>
);
}