mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 04:14:46 +08:00
feat: add notification
This commit is contained in:
@@ -38,6 +38,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@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": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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':
|
'@radix-ui/react-switch':
|
||||||
specifier: ^1.2.6
|
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)
|
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':
|
'@radix-ui/react-toggle':
|
||||||
specifier: ^1.1.10
|
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)
|
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':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-toggle-group@1.1.11':
|
||||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6383,6 +6399,22 @@ snapshots:
|
|||||||
'@types/react': 19.2.8
|
'@types/react': 19.2.8
|
||||||
'@types/react-dom': 19.2.3(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { type AgentThread } from "@/core/threads";
|
import { type AgentThread } from "@/core/threads";
|
||||||
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
||||||
@@ -60,10 +61,19 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [threadIdFromPath]);
|
}, [threadIdFromPath]);
|
||||||
|
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const thread = useThreadStream({
|
const thread = useThreadStream({
|
||||||
isNewThread,
|
isNewThread,
|
||||||
threadId,
|
threadId,
|
||||||
|
onFinish: (state) => {
|
||||||
|
if (document.hidden || !document.hasFocus()) {
|
||||||
|
showNotification(state.title, {
|
||||||
|
body: `Conversation finished`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
let result = isNewThread
|
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,
|
DownloadIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
PackageIcon,
|
||||||
SquareArrowOutUpRightIcon,
|
SquareArrowOutUpRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useMemo, useState } 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 { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
@@ -156,6 +154,15 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArtifactActions>
|
<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 && (
|
{!isWriteFile && (
|
||||||
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
@@ -241,7 +248,7 @@ export function ArtifactFilePreview({
|
|||||||
content: string;
|
content: string;
|
||||||
language: string;
|
language: string;
|
||||||
}) {
|
}) {
|
||||||
const { citations, cleanContent, citationMap } = React.useMemo(() => {
|
const { cleanContent, citationMap } = React.useMemo(() => {
|
||||||
const parsed = parseCitations(content ?? "");
|
const parsed = parseCitations(content ?? "");
|
||||||
const map = buildCitationMap(parsed.citations);
|
const map = buildCitationMap(parsed.citations);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DownloadIcon } from "lucide-react";
|
import { DownloadIcon, PackageIcon } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -48,6 +48,22 @@ export function ArtifactFileList({
|
|||||||
{getFileExtensionDisplayName(file)} file
|
{getFileExtensionDisplayName(file)} file
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardAction>
|
<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
|
<a
|
||||||
href={urlOfArtifact({
|
href={urlOfArtifact({
|
||||||
filepath: file,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { PaletteIcon, SparklesIcon, WrenchIcon } from "lucide-react";
|
import { BellIcon, PaletteIcon, SparklesIcon, WrenchIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +12,18 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { AcknowledgePage } from "@/components/workspace/settings/acknowledge-page";
|
import { AcknowledgePage } from "@/components/workspace/settings/acknowledge-page";
|
||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-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 { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SettingsSection = "appearance" | "tools" | "skills" | "acknowledge";
|
type SettingsSection =
|
||||||
|
| "appearance"
|
||||||
|
| "tools"
|
||||||
|
| "skills"
|
||||||
|
| "notification"
|
||||||
|
| "acknowledge";
|
||||||
|
|
||||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||||
defaultSection?: SettingsSection;
|
defaultSection?: SettingsSection;
|
||||||
@@ -38,6 +44,11 @@ export function SettingsDialog({
|
|||||||
label: t.settings.sections.appearance,
|
label: t.settings.sections.appearance,
|
||||||
icon: PaletteIcon,
|
icon: PaletteIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "notification",
|
||||||
|
label: t.settings.sections.notification,
|
||||||
|
icon: BellIcon,
|
||||||
|
},
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||||
],
|
],
|
||||||
@@ -45,6 +56,7 @@ export function SettingsDialog({
|
|||||||
t.settings.sections.appearance,
|
t.settings.sections.appearance,
|
||||||
t.settings.sections.tools,
|
t.settings.sections.tools,
|
||||||
t.settings.sections.skills,
|
t.settings.sections.skills,
|
||||||
|
t.settings.sections.notification,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +101,7 @@ export function SettingsDialog({
|
|||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "skills" && <SkillSettingsPage />}
|
{activeSection === "skills" && <SkillSettingsPage />}
|
||||||
|
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||||
{activeSection === "acknowledge" && <AcknowledgePage />}
|
{activeSection === "acknowledge" && <AcknowledgePage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export function SettingsSection({
|
|||||||
return (
|
return (
|
||||||
<section className={cn(className)}>
|
<section className={cn(className)}>
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-muted-foreground text-sm">{description}</p>
|
<div className="text-muted-foreground text-sm">{description}</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mt-4">{children}</main>
|
<main className="mt-4">{children}</main>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
ItemDescription,
|
ItemDescription,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
@@ -47,61 +49,34 @@ export function SkillSettingsPage() {
|
|||||||
|
|
||||||
function SkillSettingsList({ skills }: { skills: Skill[] }) {
|
function SkillSettingsList({ skills }: { skills: Skill[] }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [filter, setFilter] = useState<"public" | "custom">("public");
|
const [filter, setFilter] = useState<string>("public");
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const { mutate: enableSkill } = useEnableSkill();
|
||||||
const filteredSkills = useMemo(
|
const filteredSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.category === filter),
|
() => skills.filter((skill) => skill.category === filter),
|
||||||
[skills, filter],
|
[skills, filter],
|
||||||
);
|
);
|
||||||
if (skills.length === 0) {
|
const handleCreateSkill = () => {
|
||||||
return (
|
console.log("create skill");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<header className="flex gap-2">
|
<header className="flex justify-between">
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
className="rounded-xl"
|
<Tabs defaultValue="public" onValueChange={setFilter}>
|
||||||
size="sm"
|
<TabsList variant="line">
|
||||||
variant={filter === "public" ? "default" : "outline"}
|
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
||||||
onClick={() => setFilter("public")}
|
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
||||||
>
|
</TabsList>
|
||||||
{t.common.public}
|
</Tabs>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
<div>
|
||||||
className="rounded-xl"
|
<Button variant="outline" size="sm" onClick={handleCreateSkill}>
|
||||||
size="sm"
|
Create Skill
|
||||||
variant={filter === "custom" ? "default" : "outline"}
|
</Button>
|
||||||
onClick={() => setFilter("custom")}
|
</div>
|
||||||
>
|
|
||||||
{t.common.custom}
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
{filteredSkills.length === 0 && (
|
{filteredSkills.length === 0 && (
|
||||||
<Empty>
|
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
{filteredSkills.length > 0 &&
|
{filteredSkills.length > 0 &&
|
||||||
filteredSkills.map((skill) => (
|
filteredSkills.map((skill) => (
|
||||||
@@ -128,3 +103,23 @@ function SkillSettingsList({ skills }: { skills: Skill[] }) {
|
|||||||
</div>
|
</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;
|
filepath: string;
|
||||||
threadId: 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 response = await fetch(url);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const enUS: Translations = {
|
|||||||
preview: "Preview",
|
preview: "Preview",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
|
install: "Install",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
@@ -125,6 +126,7 @@ export const enUS: Translations = {
|
|||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
tools: "Tools",
|
tools: "Tools",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
|
notification: "Notification",
|
||||||
acknowledge: "Acknowledge",
|
acknowledge: "Acknowledge",
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
@@ -149,6 +151,19 @@ export const enUS: Translations = {
|
|||||||
description:
|
description:
|
||||||
"Manage the configuration and enabled status of the agent skills.",
|
"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: {
|
acknowledge: {
|
||||||
emptyTitle: "Acknowledgements",
|
emptyTitle: "Acknowledgements",
|
||||||
emptyDescription: "Credits and acknowledgements will show here.",
|
emptyDescription: "Credits and acknowledgements will show here.",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Translations {
|
|||||||
preview: string;
|
preview: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
save: string;
|
save: string;
|
||||||
|
install: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
@@ -119,6 +120,7 @@ export interface Translations {
|
|||||||
appearance: string;
|
appearance: string;
|
||||||
tools: string;
|
tools: string;
|
||||||
skills: string;
|
skills: string;
|
||||||
|
notification: string;
|
||||||
acknowledge: string;
|
acknowledge: string;
|
||||||
};
|
};
|
||||||
appearance: {
|
appearance: {
|
||||||
@@ -141,6 +143,17 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
notification: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
requestPermission: string;
|
||||||
|
deniedHint: string;
|
||||||
|
testButton: string;
|
||||||
|
testTitle: string;
|
||||||
|
testBody: string;
|
||||||
|
notSupported: string;
|
||||||
|
disableNotification: string;
|
||||||
|
};
|
||||||
acknowledge: {
|
acknowledge: {
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const zhCN: Translations = {
|
|||||||
preview: "预览",
|
preview: "预览",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
|
install: "安装",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
@@ -122,6 +123,7 @@ export const zhCN: Translations = {
|
|||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
tools: "工具",
|
tools: "工具",
|
||||||
skills: "技能",
|
skills: "技能",
|
||||||
|
notification: "通知",
|
||||||
acknowledge: "致谢",
|
acknowledge: "致谢",
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
@@ -144,6 +146,19 @@ export const zhCN: Translations = {
|
|||||||
title: "技能",
|
title: "技能",
|
||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
},
|
},
|
||||||
|
notification: {
|
||||||
|
title: "通知",
|
||||||
|
description:
|
||||||
|
"DeerFlow 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||||
|
requestPermission: "请求通知权限",
|
||||||
|
deniedHint:
|
||||||
|
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
||||||
|
testButton: "发送测试通知",
|
||||||
|
testTitle: "DeerFlow",
|
||||||
|
testBody: "这是一条测试通知。",
|
||||||
|
notSupported: "当前浏览器不支持通知功能。",
|
||||||
|
disableNotification: "关闭通知",
|
||||||
|
},
|
||||||
acknowledge: {
|
acknowledge: {
|
||||||
emptyTitle: "致谢",
|
emptyTitle: "致谢",
|
||||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
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";
|
import type { AgentThreadContext } from "../threads";
|
||||||
|
|
||||||
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||||
|
notification: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
@@ -13,6 +16,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||||
|
|
||||||
export interface LocalSettings {
|
export interface LocalSettings {
|
||||||
|
notification: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
"thread_id" | "is_plan_mode" | "thinking_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled"
|
||||||
@@ -42,6 +48,10 @@ export function getLocalSettings(): LocalSettings {
|
|||||||
...DEFAULT_LOCAL_SETTINGS.layout,
|
...DEFAULT_LOCAL_SETTINGS.layout,
|
||||||
...settings.layout,
|
...settings.layout,
|
||||||
},
|
},
|
||||||
|
notification: {
|
||||||
|
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||||
|
...settings.notification,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return mergedSettings;
|
return mergedSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,13 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
|||||||
);
|
);
|
||||||
return response.json();
|
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({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
isNewThread,
|
isNewThread,
|
||||||
|
onFinish,
|
||||||
}: {
|
}: {
|
||||||
isNewThread: boolean;
|
isNewThread: boolean;
|
||||||
threadId: string | null | undefined;
|
threadId: string | null | undefined;
|
||||||
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const thread = useStream<AgentThreadState>({
|
const thread = useStream<AgentThreadState>({
|
||||||
@@ -30,6 +32,7 @@ export function useThreadStream({
|
|||||||
reconnectOnMount: true,
|
reconnectOnMount: true,
|
||||||
fetchStateHistory: true,
|
fetchStateHistory: true,
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
|
onFinish?.(state.values);
|
||||||
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
queryClient.setQueriesData(
|
queryClient.setQueriesData(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user