Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # frontend-web/src/components/workspace/agents/agent-gallery.tsx # frontend-web/src/components/workspace/settings/skill-settings-page.tsx
This commit is contained in:
commit
afc517b3a7
13 files changed
+504
-372
No files matched your search
@@ -16,7 +16,7 @@ import { AgentCard } from "./agent-card";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
@@ -89,8 +89,8 @@ export function AgentGallery() {
|
||||
>
|
||||
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="mine">自己的</TabsTrigger>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useDeleteNotification,
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationItem } from "@/core/notifications";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BELL_LIMIT = 20;
|
||||
|
||||
export function NotificationBell() {
|
||||
const { t } = useI18n();
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Render badge from the lightweight count endpoint so the bell stays cheap
|
||||
// when nobody opens it. Only when the user actually opens the dropdown do
|
||||
// we pay for the full list query.
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useNotifications({ limit: BELL_LIMIT });
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
const removeNotification = useDeleteNotification();
|
||||
|
||||
const hasUnread = unreadCount > 0;
|
||||
const items = notifications ?? [];
|
||||
|
||||
async function handleClickItem(notification: NotificationItem) {
|
||||
if (notification.read) return;
|
||||
try {
|
||||
await markRead.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
if (!hasUnread) return;
|
||||
try {
|
||||
await markAllRead.mutateAsync();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notification: NotificationItem) {
|
||||
try {
|
||||
await removeNotification.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
aria-label={t.notifications.bellAria}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<div className="relative">
|
||||
<BellIcon className="size-4" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{t.notifications.title}</span>
|
||||
{hasUnread && (
|
||||
<span className="text-muted-foreground ml-auto text-xs">
|
||||
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div className="relative">
|
||||
<BellIcon className="text-sidebar-foreground/80 size-4" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="w-96 p-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t.notifications.title}
|
||||
</span>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="text-muted-foreground size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!hasUnread || markAllRead.isPending}
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
>
|
||||
<CheckCheckIcon className="mr-1 size-3.5" />
|
||||
{t.notifications.markAllRead}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t.notifications.empty}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{items.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
|
||||
!notification.read && "bg-blue-500/5",
|
||||
)}
|
||||
onClick={() => void handleClickItem(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!notification.read && (
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
!notification.read && "font-medium",
|
||||
)}
|
||||
title={notification.title}
|
||||
>
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
{notification.created_at && (
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(notification);
|
||||
}}
|
||||
aria-label={t.notifications.deleteAria}
|
||||
>
|
||||
<Trash2Icon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CheckCheckIcon,
|
||||
ChevronLeftIcon,
|
||||
Loader2,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import {
|
||||
useDeleteNotification,
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationItem } from "@/core/notifications";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BELL_LIMIT = 20;
|
||||
|
||||
type NotificationView = "list" | "settings";
|
||||
|
||||
export function NotificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [view, setView] = useState<NotificationView>("list");
|
||||
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useNotifications({ limit: BELL_LIMIT });
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
const removeNotification = useDeleteNotification();
|
||||
|
||||
const hasUnread = unreadCount > 0;
|
||||
const items = notifications ?? [];
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next);
|
||||
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
|
||||
if (!next) setView("list");
|
||||
}
|
||||
|
||||
async function handleClickItem(notification: NotificationItem) {
|
||||
if (notification.read) return;
|
||||
try {
|
||||
await markRead.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
if (!hasUnread) return;
|
||||
try {
|
||||
await markAllRead.mutateAsync();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notification: NotificationItem) {
|
||||
try {
|
||||
await removeNotification.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{view === "list" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4" />
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.title}
|
||||
</DialogTitle>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="text-muted-foreground size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!hasUnread || markAllRead.isPending}
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
>
|
||||
<CheckCheckIcon className="mr-1 size-3.5" />
|
||||
{t.notifications.markAllRead}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.settingsAria}
|
||||
onClick={() => setView("settings")}
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
|
||||
<BellIcon className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.notifications.empty}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
|
||||
!notification.read
|
||||
? "border-blue-500/30 bg-blue-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
onClick={() => void handleClickItem(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.read ? "bg-transparent" : "bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
notification.read
|
||||
? "font-medium"
|
||||
: "font-semibold",
|
||||
)}
|
||||
title={notification.title}
|
||||
>
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
{notification.created_at && (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(notification);
|
||||
}}
|
||||
aria-label={t.notifications.deleteAria}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<NotificationSettingsView onBack={() => setView("list")} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
|
||||
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 = (enabled: boolean) => {
|
||||
setSettings("notification", { enabled });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.backAria}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.settingsTitle}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
|
||||
{!isSupported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.notSupported}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.description}
|
||||
</p>
|
||||
<Switch
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
}
|
||||
onCheckedChange={handleEnableNotification}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<Button onClick={handleTestNotification} variant="outline">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.testButton}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"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,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -21,8 +14,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-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";
|
||||
@@ -32,8 +23,6 @@ type SettingsSection =
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
@@ -61,18 +50,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
label: t.settings.sections.appearance,
|
||||
icon: PaletteIcon,
|
||||
},
|
||||
{
|
||||
id: "notification",
|
||||
label: t.settings.sections.notification,
|
||||
icon: BellIcon,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: t.settings.sections.memory,
|
||||
icon: BrainIcon,
|
||||
},
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
@@ -84,8 +67,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
);
|
||||
@@ -135,8 +116,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "skills" && <SkillSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkillContent,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -83,7 +84,7 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
@@ -96,9 +97,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// Visible-to-me skills, classified into the three buckets the UI cares about.
|
||||
// "All" = built-in + anyone-published-custom + mine (active state only).
|
||||
// "Mine" = strictly my own custom skills (active state only).
|
||||
// 把可见技能分类成 UI 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||
const all: Skill[] = [];
|
||||
const mine: Skill[] = [];
|
||||
@@ -110,8 +111,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const isOwnedByMe =
|
||||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||
const isPublishedByOthers =
|
||||
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
|
||||
|
||||
if (state === "stale") {
|
||||
stale.push(skill);
|
||||
@@ -122,7 +121,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
continue;
|
||||
}
|
||||
// state === active
|
||||
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
||||
if (isBuiltin || !!skill.published) {
|
||||
all.push(skill);
|
||||
}
|
||||
if (isOwnedByMe) {
|
||||
@@ -151,8 +150,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
@@ -266,6 +265,7 @@ function SkillRow({
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -316,7 +316,10 @@ function SkillRow({
|
||||
return (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent>
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
@@ -504,10 +507,55 @@ function SkillRow({
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailDialog({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: Skill;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogDescription>{skill.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive p-4 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
|
||||
{data?.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
MessageSquareCodeIcon,
|
||||
@@ -27,8 +28,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
@@ -58,11 +60,13 @@ export function WorkspaceNavMenu() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "notification" | "about"
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -75,10 +79,11 @@ export function WorkspaceNavMenu() {
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
<NotificationBell />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
@@ -96,6 +101,15 @@ export function WorkspaceNavMenu() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
|
||||
@@ -177,10 +177,10 @@ export const enUS: Translations = {
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "Agents",
|
||||
title: "Experts",
|
||||
description:
|
||||
"Create and manage custom agents with specialized prompts and capabilities.",
|
||||
newAgent: "New Agent",
|
||||
"Create and manage custom experts with specialized prompts and capabilities.",
|
||||
newAgent: "New Expert",
|
||||
emptyTitle: "No custom agents yet",
|
||||
emptyDescription:
|
||||
"Create your first custom agent with a specialized system prompt.",
|
||||
@@ -456,7 +456,7 @@ export const enUS: Translations = {
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||
tabAll: "All",
|
||||
tabAll: "Square",
|
||||
tabMine: "Mine",
|
||||
entryStale: "Stale {count}",
|
||||
entryArchived: "Archived {count}",
|
||||
@@ -666,5 +666,8 @@ export const enUS: Translations = {
|
||||
markAllRead: "Mark all read",
|
||||
empty: "No notifications",
|
||||
deleteAria: "Delete notification",
|
||||
settingsAria: "Notification settings",
|
||||
backAria: "Back",
|
||||
settingsTitle: "Notification settings",
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
|
||||
markAllRead: string;
|
||||
empty: string;
|
||||
deleteAria: string;
|
||||
settingsAria: string;
|
||||
backAria: string;
|
||||
settingsTitle: string;
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
CompassIcon,
|
||||
GraduationCapIcon,
|
||||
ImageIcon,
|
||||
@@ -89,7 +89,7 @@ export const zhCN: Translations = {
|
||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||
proMode: "专业问答",
|
||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||
ultraMode: "多智能体问答",
|
||||
ultraMode: "多专家问答",
|
||||
ultraModeDescription:
|
||||
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||
reasoningEffort: "推理深度",
|
||||
@@ -165,47 +165,47 @@ export const zhCN: Translations = {
|
||||
chats: "对话",
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
agents: "专家",
|
||||
users: "用户管理",
|
||||
},
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "智能体",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
||||
newAgent: "新建智能体",
|
||||
emptyTitle: "还没有自定义智能体",
|
||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
||||
title: "专家",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义专家。",
|
||||
newAgent: "新建专家",
|
||||
emptyTitle: "还没有自定义专家",
|
||||
emptyDescription: "创建你的第一个自定义专家,设置专属系统提示词。",
|
||||
chat: "对话",
|
||||
delete: "删除",
|
||||
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
|
||||
deleteSuccess: "智能体已删除",
|
||||
deleteConfirm: "确定要删除该专家吗?此操作不可撤销。",
|
||||
deleteSuccess: "专家已删除",
|
||||
newChat: "新对话",
|
||||
createPageTitle: "设计你的智能体",
|
||||
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
|
||||
nameStepTitle: "给新智能体起个名字",
|
||||
createPageTitle: "设计你的专家",
|
||||
createPageSubtitle: "描述你想要的专家,我来帮你通过对话创建。",
|
||||
nameStepTitle: "给新专家起个名字",
|
||||
nameStepHint:
|
||||
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
||||
nameStepPlaceholder: "例如 code-reviewer",
|
||||
nameStepContinue: "继续",
|
||||
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
||||
nameStepAlreadyExistsError: "已存在同名智能体",
|
||||
nameStepAlreadyExistsError: "已存在同名专家",
|
||||
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||
nameStepBootstrapMessage:
|
||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存智能体",
|
||||
saving: "正在保存智能体...",
|
||||
"新专家的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存专家",
|
||||
saving: "正在保存专家...",
|
||||
saveRequested:
|
||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版专家。",
|
||||
saveHint:
|
||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||
"你可以在右上角的菜单里随时保存这个专家,就算目前还只是初稿也可以。",
|
||||
saveCommandMessage:
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义专家。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
agentCreatedPendingRefresh:
|
||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
"专家已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
more: "更多操作",
|
||||
agentCreated: "智能体已创建!",
|
||||
agentCreated: "专家已创建!",
|
||||
startChatting: "开始对话",
|
||||
backToGallery: "返回 Gallery",
|
||||
},
|
||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||
tabAll: "全部",
|
||||
tabAll: "广场",
|
||||
tabMine: "我的",
|
||||
entryStale: "过时 {count}",
|
||||
entryArchived: "归档 {count}",
|
||||
@@ -583,12 +583,12 @@ export const zhCN: Translations = {
|
||||
|
||||
adminUsers: {
|
||||
title: "用户管理",
|
||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
||||
description: "查看所有注册用户及其关联的技能和专家数量。",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
userId: "用户 ID",
|
||||
skillCount: "技能数",
|
||||
agentCount: "智能体数",
|
||||
agentCount: "专家数",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
empty: "暂无用户",
|
||||
@@ -635,5 +635,8 @@ export const zhCN: Translations = {
|
||||
markAllRead: "全部已读",
|
||||
empty: "暂无通知",
|
||||
deleteAria: "删除通知",
|
||||
settingsAria: "通知设置",
|
||||
backAria: "返回",
|
||||
settingsTitle: "通知设置",
|
||||
},
|
||||
};
|
||||
@@ -185,6 +185,24 @@ export async function setSkillPinned(
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillContentResponse {
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getSkillContent(
|
||||
skillName: string,
|
||||
): Promise<SkillContentResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill content");
|
||||
}
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
@@ -29,6 +30,14 @@ export function useSkills(params: { search?: string; tagId?: string | null } = {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
queryFn: () => getSkillContent(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in new issue
Block a user