feat: add notification

This commit is contained in:
Henry Li
2026-01-31 11:08:27 +08:00
parent 835fd4d0c7
commit 5295f5b5b9
18 changed files with 482 additions and 56 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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
? ""

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

View File

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

View File

@@ -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.",

View File

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

View File

@@ -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: "相关的致谢信息会展示在这里。",

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

View File

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

View File

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

View File

@@ -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(
{