diff --git a/frontend/package.json b/frontend/package.json index 5400182..082ab83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6a8ad09..4359814 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 728248c..0a17d60 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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 ? "" diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..441c5d4 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 8fb08d2..b2b9f59 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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({
+ {!isWriteFile && filepath.endsWith(".skill") && ( + + + + )} {!isWriteFile && ( { + const { cleanContent, citationMap } = React.useMemo(() => { const parsed = parseCitations(content ?? ""); const map = buildCitationMap(parsed.citations); return { diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx index 072c7db..fbd1b78 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx @@ -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 + {file.endsWith(".skill") && ( + e.stopPropagation()} + > + + + )} { + 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 ( + +

+ {t.settings.notification.notSupported} +

+
+ ); + } + + return ( + +
{t.settings.notification.description}
+
+ +
+
+ } + > +
+ {permission === "default" && ( + + )} + + {permission === "denied" && ( +

+ {t.settings.notification.deniedHint} +

+ )} + + {permission === "granted" && settings.notification.enabled && ( +
+ +
+ )} +
+ + ); +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index 40dcf84..fd8a98f 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -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 & { 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" && } {activeSection === "tools" && } {activeSection === "skills" && } + {activeSection === "notification" && } {activeSection === "acknowledge" && } diff --git a/frontend/src/components/workspace/settings/settings-section.tsx b/frontend/src/components/workspace/settings/settings-section.tsx index 95bc40e..957ead8 100644 --- a/frontend/src/components/workspace/settings/settings-section.tsx +++ b/frontend/src/components/workspace/settings/settings-section.tsx @@ -14,9 +14,9 @@ export function SettingsSection({ return (
-

{title}

+
{title}
{description && ( -

{description}

+
{description}
)}
{children}
diff --git a/frontend/src/components/workspace/settings/skill-settings-page.tsx b/frontend/src/components/workspace/settings/skill-settings-page.tsx index 5c22cee..9546191 100644 --- a/frontend/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend/src/components/workspace/settings/skill-settings-page.tsx @@ -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("public"); const { mutate: enableSkill } = useEnableSkill(); const filteredSkills = useMemo( () => skills.filter((skill) => skill.category === filter), [skills, filter], ); - if (skills.length === 0) { - return ( - - - - - - No agent skill yet - - Put your agent skill folders under the `/skills/custom` folder under - the root folder of DeerFlow. - - - - ); - } + const handleCreateSkill = () => { + console.log("create skill"); + }; return (
-
- - +
+
+ + + {t.common.public} + {t.common.custom} + + +
+
+ +
{filteredSkills.length === 0 && ( - - - - - - No skill yet - - Put your skill folders under the `skills/{filter}` folder under - the root folder of DeerFlow. - - - + )} {filteredSkills.length > 0 && filteredSkills.map((skill) => ( @@ -128,3 +103,23 @@ function SkillSettingsList({ skills }: { skills: Skill[] }) {
); } + +function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { + return ( + + + + + + No agent skill yet + + Put your agent skill folders under the `/skills/custom` folder under + the root folder of DeerFlow. + + + + + + + ); +} diff --git a/frontend/src/core/artifacts/loader.ts b/frontend/src/core/artifacts/loader.ts index 915b1aa..aa2b8b4 100644 --- a/frontend/src/core/artifacts/loader.ts +++ b/frontend/src/core/artifacts/loader.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 21c78f5..21c8bc6 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index cccf73c..dd1860d 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 5bd4c74..b416f62 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "相关的致谢信息会展示在这里。", diff --git a/frontend/src/core/notification/hooks.ts b/frontend/src/core/notification/hooks.ts new file mode 100644 index 0000000..102e750 --- /dev/null +++ b/frontend/src/core/notification/hooks.ts @@ -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; + showNotification: (title: string, options?: NotificationOptions) => void; +} + +export function useNotification(): UseNotificationReturn { + const [permission, setPermission] = + useState("default"); + const [isSupported, setIsSupported] = useState(false); + + const lastNotificationTime = useRef(new Date()); + + useEffect(() => { + // Check if browser supports Notification API + if ("Notification" in window) { + setIsSupported(true); + setPermission(Notification.permission); + } + }, []); + + const requestPermission = + useCallback(async (): Promise => { + 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, + }; +} diff --git a/frontend/src/core/settings/local.ts b/frontend/src/core/settings/local.ts index 6d284fc..c5e1242 100644 --- a/frontend/src/core/settings/local.ts +++ b/frontend/src/core/settings/local.ts @@ -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; } diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index a558492..d950fed 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -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(); +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 408c2dd..dbb0e1d 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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({ @@ -30,6 +32,7 @@ export function useThreadStream({ reconnectOnMount: true, fetchStateHistory: true, onFinish(state) { + onFinish?.(state.values); // void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); queryClient.setQueriesData( {