mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
feat: add notification
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
|
||||
32
frontend/pnpm-lock.yaml
generated
32
frontend/pnpm-lock.yaml
generated
@@ -71,6 +71,9 @@ importers:
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.2.6
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -1378,6 +1381,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.11':
|
||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||
peerDependencies:
|
||||
@@ -6383,6 +6399,22 @@ snapshots:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { type AgentThread } from "@/core/threads";
|
||||
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
||||
@@ -60,10 +61,19 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [threadIdFromPath]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const thread = useThreadStream({
|
||||
isNewThread,
|
||||
threadId,
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
showNotification(state.title, {
|
||||
body: `Conversation finished`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const title = useMemo(() => {
|
||||
let result = isNewThread
|
||||
? ""
|
||||
|
||||
91
frontend/src/components/ui/tabs.tsx
Normal file
91
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ export async function loadArtifactContent({
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
}) {
|
||||
const url = urlOfArtifact({ filepath, threadId });
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath.replace(".md", ".skill/SKILL.md");
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId });
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
return text;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const enUS: Translations = {
|
||||
preview: "Preview",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
install: "Install",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
@@ -125,6 +126,7 @@ export const enUS: Translations = {
|
||||
appearance: "Appearance",
|
||||
tools: "Tools",
|
||||
skills: "Skills",
|
||||
notification: "Notification",
|
||||
acknowledge: "Acknowledge",
|
||||
},
|
||||
appearance: {
|
||||
@@ -149,6 +151,19 @@ export const enUS: Translations = {
|
||||
description:
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
},
|
||||
notification: {
|
||||
title: "Notification",
|
||||
description:
|
||||
"DeerFlow only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
|
||||
requestPermission: "Request notification permission",
|
||||
deniedHint:
|
||||
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
||||
testButton: "Send test notification",
|
||||
testTitle: "DeerFlow",
|
||||
testBody: "This is a test notification.",
|
||||
notSupported: "Your browser does not support notifications.",
|
||||
disableNotification: "Disable notification",
|
||||
},
|
||||
acknowledge: {
|
||||
emptyTitle: "Acknowledgements",
|
||||
emptyDescription: "Credits and acknowledgements will show here.",
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Translations {
|
||||
preview: string;
|
||||
cancel: string;
|
||||
save: string;
|
||||
install: string;
|
||||
};
|
||||
|
||||
// Welcome
|
||||
@@ -119,6 +120,7 @@ export interface Translations {
|
||||
appearance: string;
|
||||
tools: string;
|
||||
skills: string;
|
||||
notification: string;
|
||||
acknowledge: string;
|
||||
};
|
||||
appearance: {
|
||||
@@ -141,6 +143,17 @@ export interface Translations {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
notification: {
|
||||
title: string;
|
||||
description: string;
|
||||
requestPermission: string;
|
||||
deniedHint: string;
|
||||
testButton: string;
|
||||
testTitle: string;
|
||||
testBody: string;
|
||||
notSupported: string;
|
||||
disableNotification: string;
|
||||
};
|
||||
acknowledge: {
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const zhCN: Translations = {
|
||||
preview: "预览",
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
install: "安装",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
@@ -122,6 +123,7 @@ export const zhCN: Translations = {
|
||||
appearance: "外观",
|
||||
tools: "工具",
|
||||
skills: "技能",
|
||||
notification: "通知",
|
||||
acknowledge: "致谢",
|
||||
},
|
||||
appearance: {
|
||||
@@ -144,6 +146,19 @@ export const zhCN: Translations = {
|
||||
title: "技能",
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
},
|
||||
notification: {
|
||||
title: "通知",
|
||||
description:
|
||||
"DeerFlow 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||
requestPermission: "请求通知权限",
|
||||
deniedHint:
|
||||
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
||||
testButton: "发送测试通知",
|
||||
testTitle: "DeerFlow",
|
||||
testBody: "这是一条测试通知。",
|
||||
notSupported: "当前浏览器不支持通知功能。",
|
||||
disableNotification: "关闭通知",
|
||||
},
|
||||
acknowledge: {
|
||||
emptyTitle: "致谢",
|
||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
||||
|
||||
99
frontend/src/core/notification/hooks.ts
Normal file
99
frontend/src/core/notification/hooks.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
import { useLocalSettings } from "../settings";
|
||||
|
||||
interface NotificationOptions {
|
||||
body?: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
tag?: string;
|
||||
data?: unknown;
|
||||
requireInteraction?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
interface UseNotificationReturn {
|
||||
permission: NotificationPermission;
|
||||
isSupported: boolean;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
showNotification: (title: string, options?: NotificationOptions) => void;
|
||||
}
|
||||
|
||||
export function useNotification(): UseNotificationReturn {
|
||||
const [permission, setPermission] =
|
||||
useState<NotificationPermission>("default");
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
|
||||
const lastNotificationTime = useRef<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
// Check if browser supports Notification API
|
||||
if ("Notification" in window) {
|
||||
setIsSupported(true);
|
||||
setPermission(Notification.permission);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPermission =
|
||||
useCallback(async (): Promise<NotificationPermission> => {
|
||||
if (!isSupported) {
|
||||
console.warn("Notification API is not supported in this browser");
|
||||
return "denied";
|
||||
}
|
||||
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
return result;
|
||||
}, [isSupported]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
|
||||
const showNotification = useCallback(
|
||||
(title: string, options?: NotificationOptions) => {
|
||||
if (!isSupported) {
|
||||
console.warn("Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.notification.enabled) {
|
||||
console.warn("Notification is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
new Date().getTime() - lastNotificationTime.current.getTime() <
|
||||
1000
|
||||
) {
|
||||
console.warn("Notification sent too soon");
|
||||
return;
|
||||
}
|
||||
lastNotificationTime.current = new Date();
|
||||
|
||||
if (permission !== "granted") {
|
||||
console.warn("Notification permission not granted");
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification(title, options);
|
||||
|
||||
// Optional: Add event listeners
|
||||
notification.onclick = () => {
|
||||
console.log("Notification clicked");
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
|
||||
notification.onerror = (error) => {
|
||||
console.error("Notification error:", error);
|
||||
};
|
||||
},
|
||||
[isSupported, settings.notification.enabled, permission],
|
||||
);
|
||||
|
||||
return {
|
||||
permission,
|
||||
isSupported,
|
||||
requestPermission,
|
||||
showNotification,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AgentThreadContext } from "../threads";
|
||||
|
||||
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -13,6 +16,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
|
||||
export interface LocalSettings {
|
||||
notification: {
|
||||
enabled: boolean;
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled"
|
||||
@@ -42,6 +48,10 @@ export function getLocalSettings(): LocalSettings {
|
||||
...DEFAULT_LOCAL_SETTINGS.layout,
|
||||
...settings.layout,
|
||||
},
|
||||
notification: {
|
||||
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||
...settings.notification,
|
||||
},
|
||||
};
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
@@ -23,3 +23,13 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function installSkill(skillName: string) {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}/install`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
isNewThread,
|
||||
onFinish,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const thread = useStream<AgentThreadState>({
|
||||
@@ -30,6 +32,7 @@ export function useThreadStream({
|
||||
reconnectOnMount: true,
|
||||
fetchStateHistory: true,
|
||||
onFinish(state) {
|
||||
onFinish?.(state.values);
|
||||
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user