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() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
|
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagId, setTagId] = useState<string | null>(null);
|
const [tagId, setTagId] = useState<string | null>(null);
|
||||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
// 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">
|
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||||
<TabsList className="w-auto self-start">
|
<TabsList className="w-auto self-start">
|
||||||
<TabsTrigger value="all">全部</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
<TabsTrigger value="mine">自己的</TabsTrigger>
|
<TabsTrigger value="all">广场</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TagSearchBar
|
<TagSearchBar
|
||||||
targetType="agent"
|
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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||||
BellIcon,
|
|
||||||
BrainIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
UserIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -21,8 +14,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
|||||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-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 { 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";
|
||||||
@@ -32,8 +23,6 @@ type SettingsSection =
|
|||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "tools"
|
| "tools"
|
||||||
| "skills"
|
|
||||||
| "notification"
|
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||||
@@ -61,18 +50,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
label: t.settings.sections.appearance,
|
label: t.settings.sections.appearance,
|
||||||
icon: PaletteIcon,
|
icon: PaletteIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "notification",
|
|
||||||
label: t.settings.sections.notification,
|
|
||||||
icon: BellIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "memory",
|
id: "memory",
|
||||||
label: t.settings.sections.memory,
|
label: t.settings.sections.memory,
|
||||||
icon: BrainIcon,
|
icon: BrainIcon,
|
||||||
},
|
},
|
||||||
{ 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: "account",
|
id: "account",
|
||||||
label: "个人信息",
|
label: "个人信息",
|
||||||
@@ -84,8 +67,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
t.settings.sections.appearance,
|
t.settings.sections.appearance,
|
||||||
t.settings.sections.memory,
|
t.settings.sections.memory,
|
||||||
t.settings.sections.tools,
|
t.settings.sections.tools,
|
||||||
t.settings.sections.skills,
|
|
||||||
t.settings.sections.notification,
|
|
||||||
t.settings.sections.about,
|
t.settings.sections.about,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -135,8 +116,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
|
||||||
{activeSection === "skills" && <SkillSettingsPage />}
|
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
|
useSkillContent,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -83,7 +84,7 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("mine");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||||
@@ -96,9 +97,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
const isAdmin = storedAuth?.system_role === "admin";
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
|
|
||||||
// Visible-to-me skills, classified into the three buckets the UI cares about.
|
// 把可见技能分类成 UI 关心的几个桶。
|
||||||
// "All" = built-in + anyone-published-custom + mine (active state only).
|
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||||
// "Mine" = strictly my own custom skills (active state only).
|
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||||
const all: Skill[] = [];
|
const all: Skill[] = [];
|
||||||
const mine: Skill[] = [];
|
const mine: Skill[] = [];
|
||||||
@@ -110,8 +111,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const isOwnedByMe =
|
const isOwnedByMe =
|
||||||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||||
const isPublishedByOthers =
|
|
||||||
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
|
|
||||||
|
|
||||||
if (state === "stale") {
|
if (state === "stale") {
|
||||||
stale.push(skill);
|
stale.push(skill);
|
||||||
@@ -122,7 +121,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// state === active
|
// state === active
|
||||||
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
if (isBuiltin || !!skill.published) {
|
||||||
all.push(skill);
|
all.push(skill);
|
||||||
}
|
}
|
||||||
if (isOwnedByMe) {
|
if (isOwnedByMe) {
|
||||||
@@ -151,8 +150,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs value={filter} onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
|
||||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
@@ -266,6 +265,7 @@ function SkillRow({
|
|||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
@@ -316,7 +316,10 @@ function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full" variant="outline">
|
<Item className="w-full" variant="outline">
|
||||||
<ItemContent>
|
<ItemContent
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailOpen(true)}
|
||||||
|
>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span>{skill.name}</span>
|
<span>{skill.name}</span>
|
||||||
@@ -504,10 +507,55 @@ function SkillRow({
|
|||||||
currentTags={skill.tags ?? []}
|
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 }) {
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BellIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
MessageSquareCodeIcon,
|
MessageSquareCodeIcon,
|
||||||
@@ -27,8 +28,9 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { SettingsDialog } from "./settings";
|
import { SettingsDialog } from "./settings";
|
||||||
@@ -58,11 +60,13 @@ export function WorkspaceNavMenu() {
|
|||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||||
"appearance" | "memory" | "tools" | "notification" | "about"
|
"appearance" | "memory" | "tools" | "about"
|
||||||
>("appearance");
|
>("appearance");
|
||||||
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { data: unreadCount = 0 } = useUnreadCount();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -75,10 +79,11 @@ export function WorkspaceNavMenu() {
|
|||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
defaultSection={settingsDefaultSection}
|
defaultSection={settingsDefaultSection}
|
||||||
/>
|
/>
|
||||||
|
<NotificationDialog
|
||||||
|
open={notificationOpen}
|
||||||
|
onOpenChange={setNotificationOpen}
|
||||||
|
/>
|
||||||
<SidebarMenu className="w-full">
|
<SidebarMenu className="w-full">
|
||||||
<SidebarMenuItem>
|
|
||||||
<NotificationBell />
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -96,6 +101,15 @@ export function WorkspaceNavMenu() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<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
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultSection("appearance");
|
setSettingsDefaultSection("appearance");
|
||||||
|
|||||||
@@ -177,10 +177,10 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: {
|
agents: {
|
||||||
title: "Agents",
|
title: "Experts",
|
||||||
description:
|
description:
|
||||||
"Create and manage custom agents with specialized prompts and capabilities.",
|
"Create and manage custom experts with specialized prompts and capabilities.",
|
||||||
newAgent: "New Agent",
|
newAgent: "New Expert",
|
||||||
emptyTitle: "No custom agents yet",
|
emptyTitle: "No custom agents yet",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Create your first custom agent with a specialized system prompt.",
|
"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",
|
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||||
badgeSourceUpload: "Uploaded",
|
badgeSourceUpload: "Uploaded",
|
||||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||||
tabAll: "All",
|
tabAll: "Square",
|
||||||
tabMine: "Mine",
|
tabMine: "Mine",
|
||||||
entryStale: "Stale {count}",
|
entryStale: "Stale {count}",
|
||||||
entryArchived: "Archived {count}",
|
entryArchived: "Archived {count}",
|
||||||
@@ -666,5 +666,8 @@ export const enUS: Translations = {
|
|||||||
markAllRead: "Mark all read",
|
markAllRead: "Mark all read",
|
||||||
empty: "No notifications",
|
empty: "No notifications",
|
||||||
deleteAria: "Delete notification",
|
deleteAria: "Delete notification",
|
||||||
|
settingsAria: "Notification settings",
|
||||||
|
backAria: "Back",
|
||||||
|
settingsTitle: "Notification settings",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
|
|||||||
markAllRead: string;
|
markAllRead: string;
|
||||||
empty: string;
|
empty: string;
|
||||||
deleteAria: string;
|
deleteAria: string;
|
||||||
|
settingsAria: string;
|
||||||
|
backAria: string;
|
||||||
|
settingsTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -89,7 +89,7 @@ export const zhCN: Translations = {
|
|||||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||||
proMode: "专业问答",
|
proMode: "专业问答",
|
||||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||||
ultraMode: "多智能体问答",
|
ultraMode: "多专家问答",
|
||||||
ultraModeDescription:
|
ultraModeDescription:
|
||||||
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||||
reasoningEffort: "推理深度",
|
reasoningEffort: "推理深度",
|
||||||
@@ -165,47 +165,47 @@ export const zhCN: Translations = {
|
|||||||
chats: "对话",
|
chats: "对话",
|
||||||
recentChats: "最近的对话",
|
recentChats: "最近的对话",
|
||||||
demoChats: "演示对话",
|
demoChats: "演示对话",
|
||||||
agents: "智能体",
|
agents: "专家",
|
||||||
users: "用户管理",
|
users: "用户管理",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: {
|
agents: {
|
||||||
title: "智能体",
|
title: "专家",
|
||||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
description: "创建和管理具有专属 Prompt 与能力的自定义专家。",
|
||||||
newAgent: "新建智能体",
|
newAgent: "新建专家",
|
||||||
emptyTitle: "还没有自定义智能体",
|
emptyTitle: "还没有自定义专家",
|
||||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
emptyDescription: "创建你的第一个自定义专家,设置专属系统提示词。",
|
||||||
chat: "对话",
|
chat: "对话",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
|
deleteConfirm: "确定要删除该专家吗?此操作不可撤销。",
|
||||||
deleteSuccess: "智能体已删除",
|
deleteSuccess: "专家已删除",
|
||||||
newChat: "新对话",
|
newChat: "新对话",
|
||||||
createPageTitle: "设计你的智能体",
|
createPageTitle: "设计你的专家",
|
||||||
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
|
createPageSubtitle: "描述你想要的专家,我来帮你通过对话创建。",
|
||||||
nameStepTitle: "给新智能体起个名字",
|
nameStepTitle: "给新专家起个名字",
|
||||||
nameStepHint:
|
nameStepHint:
|
||||||
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
||||||
nameStepPlaceholder: "例如 code-reviewer",
|
nameStepPlaceholder: "例如 code-reviewer",
|
||||||
nameStepContinue: "继续",
|
nameStepContinue: "继续",
|
||||||
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
||||||
nameStepAlreadyExistsError: "已存在同名智能体",
|
nameStepAlreadyExistsError: "已存在同名专家",
|
||||||
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
||||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||||
nameStepBootstrapMessage:
|
nameStepBootstrapMessage:
|
||||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
"新专家的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||||
save: "保存智能体",
|
save: "保存专家",
|
||||||
saving: "正在保存智能体...",
|
saving: "正在保存专家...",
|
||||||
saveRequested:
|
saveRequested:
|
||||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版专家。",
|
||||||
saveHint:
|
saveHint:
|
||||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
"你可以在右上角的菜单里随时保存这个专家,就算目前还只是初稿也可以。",
|
||||||
saveCommandMessage:
|
saveCommandMessage:
|
||||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
"请现在根据我们目前已经讨论的全部内容保存这个自定义专家。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||||
agentCreatedPendingRefresh:
|
agentCreatedPendingRefresh:
|
||||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
"专家已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||||
more: "更多操作",
|
more: "更多操作",
|
||||||
agentCreated: "智能体已创建!",
|
agentCreated: "专家已创建!",
|
||||||
startChatting: "开始对话",
|
startChatting: "开始对话",
|
||||||
backToGallery: "返回 Gallery",
|
backToGallery: "返回 Gallery",
|
||||||
},
|
},
|
||||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
|||||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||||
badgeSourceUpload: "用户上传",
|
badgeSourceUpload: "用户上传",
|
||||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||||
tabAll: "全部",
|
tabAll: "广场",
|
||||||
tabMine: "我的",
|
tabMine: "我的",
|
||||||
entryStale: "过时 {count}",
|
entryStale: "过时 {count}",
|
||||||
entryArchived: "归档 {count}",
|
entryArchived: "归档 {count}",
|
||||||
@@ -583,12 +583,12 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
title: "用户管理",
|
title: "用户管理",
|
||||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
description: "查看所有注册用户及其关联的技能和专家数量。",
|
||||||
email: "邮箱",
|
email: "邮箱",
|
||||||
role: "角色",
|
role: "角色",
|
||||||
userId: "用户 ID",
|
userId: "用户 ID",
|
||||||
skillCount: "技能数",
|
skillCount: "技能数",
|
||||||
agentCount: "智能体数",
|
agentCount: "专家数",
|
||||||
roleAdmin: "管理员",
|
roleAdmin: "管理员",
|
||||||
roleUser: "普通用户",
|
roleUser: "普通用户",
|
||||||
empty: "暂无用户",
|
empty: "暂无用户",
|
||||||
@@ -635,5 +635,8 @@ export const zhCN: Translations = {
|
|||||||
markAllRead: "全部已读",
|
markAllRead: "全部已读",
|
||||||
empty: "暂无通知",
|
empty: "暂无通知",
|
||||||
deleteAria: "删除通知",
|
deleteAria: "删除通知",
|
||||||
|
settingsAria: "通知设置",
|
||||||
|
backAria: "返回",
|
||||||
|
settingsTitle: "通知设置",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -185,6 +185,24 @@ export async function setSkillPinned(
|
|||||||
return response.json() as Promise<Skill>;
|
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> {
|
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
|
getSkillContent,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
@@ -29,6 +30,14 @@ export function useSkills(params: { search?: string; tagId?: string | null } = {
|
|||||||
return { skills: data ?? [], isLoading, error };
|
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() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -845,6 +845,36 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillContentResponse(BaseModel):
|
||||||
|
"""SKILL.md 原文内容,适用于公共和自定义技能。"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
category: SkillCategory
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/skills/{skill_name}/content",
|
||||||
|
response_model=SkillContentResponse,
|
||||||
|
summary="Get Skill Markdown Content",
|
||||||
|
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
|
||||||
|
)
|
||||||
|
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
if skill is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
content = skill.skill_file.read_text(encoding="utf-8")
|
||||||
|
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/skills/{skill_name}",
|
"/skills/{skill_name}",
|
||||||
response_model=SkillResponse,
|
response_model=SkillResponse,
|
||||||
|
|||||||
Reference in new issue
Block a user