feat(skills/notifications): 多租户阶段 4 — 测试 + 前端
后端:
tests/test_skills_multitenancy.py(12 个用例,全部通过)
- SkillStore: 可见性 / 全局重名拒绝 / owner-only update+delete /
admin override / ensure_legacy 仅补 NULL owner
- 路由助手 _get_editable_skill_or_raise / _get_owned_skill_or_raise
的 owner / admin / 403 / 404 分流
- cascade_skill_unpublished_or_deleted:
· unpublish 同时扫两个用户的 agent,剥掉 skill;自己改自己不发
通知,其他人收 agent_skill_removed
· admin_takedown 额外给 skill owner 发 skill_taken_down
- NotificationStore CRUD + unread_count + 跨用户隔离
- 同步版 apply_automatic_transitions 用户隔离:A 跑 curator 不
动 B 的 skill;curator_log per-user 分桶
前端 — core/notifications/{types,api,hooks,index}.ts
- 类型对齐 routers/notifications.py 的 NotificationItem
- listNotifications / getUnreadCount / markRead / markAllRead /
deleteNotification
- useNotifications / useUnreadCount(refetchInterval=30s) / mutation 钩子
前端 — components/workspace/notification-bell.tsx
- 顶部铃铛图标 + 未读红点(>99 显示 99+)
- 复用 DropdownMenu 作为弹层(项目暂无 Popover 原语)
- 列表项:未读蓝点 + 标题 + body + 相对时间,悬停露出删除按钮
- 标记单条已读 / 全部已读 / 删除
- 接入 workspace-nav-menu.tsx,作为 SidebarMenuItem 放在设置入口上方
前端 — Skill 多租户字段
- core/skills/type.ts 加 owner_user_id / published / builtin
- core/skills/api.ts 加 setSkillPublished + deleteCustomSkill
- core/skills/hooks.ts 加 useSetSkillPublished + useDeleteCustomSkill
前端 — settings/skill-settings-page.tsx
- 抽出 SkillRow 子组件,每项渲染 owner badge(内置/我的/来自他人/
已发布),样式抄自 agent-card.tsx 的 isBuiltin/isOwned/isFromOthers
- canManage = isOwned || isAdmin;canTogglePublish 同上但排除内置
- 发布/取消发布按钮(Eye / EyeOff),调用 setSkillPublished
- 删除按钮 + 二次确认 Dialog,调用 deleteCustomSkill
- 启用开关保持原样
前端 — i18n
- types.ts: 新增 NotificationsTranslations + Translations.notifications;
settings.skills 加 badgeBuiltin/badgeMine/badgeFromOthers(+Title) /
badgePublished(+Title) / publishAction / unpublishAction /
publishSuccess / unpublishSuccess / deleteAction / deleteConfirm /
deleteSuccess
- zh-CN.ts / en-US.ts 同步补齐对应文案
验证
- PYTHONPATH=. uv run pytest tests/test_skills_multitenancy.py -v →
12 passed in 2.08s
- 与现有 test_agents_backend / test_skill_evolution_runtime /
test_skills_install_upload 并行跑:41 通过 + 1 个 pre-existing 失败
(test_validate_rejects_missing_skill_md 期望 "SKILL.md" 但收到
"Skill archive is empty",与本期改动无关)
- pnpm typecheck:本次新增/修改的文件零新增错误;剩余报错全部为
pre-existing 的 next/link、next/navigation 别名声明缺失、
workspace-nav-menu.tsx 的 SettingsSection 类型不匹配、nextra
缺失、markdown-docx 第三方库等
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4ef96c7930
commit
99febda256
14 files changed
+1133
-21
No files matched your search
@@ -0,0 +1,212 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, TrendingUpIcon, Upload } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
EyeIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
FileArchive,
|
||||||
|
Loader2,
|
||||||
|
SparklesIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -10,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -31,11 +43,14 @@ import {
|
|||||||
} 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
|
useDeleteCustomSkill,
|
||||||
useEnableSkill,
|
useEnableSkill,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
|
useSetSkillPublished,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -80,6 +95,11 @@ function SkillSettingsList({
|
|||||||
() => skills.filter((skill) => skill.category === filter),
|
() => skills.filter((skill) => skill.category === filter),
|
||||||
[skills, filter],
|
[skills, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
|
|
||||||
const handleCreateSkill = () => {
|
const handleCreateSkill = () => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
router.push("/?mode=skill");
|
router.push("/?mode=skill");
|
||||||
@@ -127,25 +147,15 @@ function SkillSettingsList({
|
|||||||
)}
|
)}
|
||||||
{filteredSkills.length > 0 &&
|
{filteredSkills.length > 0 &&
|
||||||
filteredSkills.map((skill) => (
|
filteredSkills.map((skill) => (
|
||||||
<Item className="w-full" variant="outline" key={skill.name}>
|
<SkillRow
|
||||||
<ItemContent>
|
key={skill.name}
|
||||||
<ItemTitle>
|
skill={skill}
|
||||||
<div className="flex items-center gap-2">{skill.name}</div>
|
currentUserId={currentUserId}
|
||||||
</ItemTitle>
|
isAdmin={isAdmin}
|
||||||
<ItemDescription className="line-clamp-4">
|
onToggleEnabled={(enabled) =>
|
||||||
{skill.description}
|
enableSkill({ skillName: skill.name, enabled })
|
||||||
</ItemDescription>
|
}
|
||||||
</ItemContent>
|
/>
|
||||||
<ItemActions>
|
|
||||||
<Switch
|
|
||||||
checked={skill.enabled}
|
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
enableSkill({ skillName: skill.name, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ItemActions>
|
|
||||||
</Item>
|
|
||||||
))}
|
))}
|
||||||
<InstallSkillDialog
|
<InstallSkillDialog
|
||||||
open={installOpen}
|
open={installOpen}
|
||||||
@@ -155,6 +165,176 @@ function SkillSettingsList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkillRow({
|
||||||
|
skill,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
onToggleEnabled,
|
||||||
|
}: {
|
||||||
|
skill: Skill;
|
||||||
|
currentUserId: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onToggleEnabled: (enabled: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const setPublished = useSetSkillPublished();
|
||||||
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const isCustom = skill.category === "custom";
|
||||||
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
|
// the multitenancy fields still classify public skills correctly.
|
||||||
|
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||||
|
const isOwned =
|
||||||
|
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
|
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||||
|
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||||
|
const canManage = isCustom && (isOwned || isAdmin);
|
||||||
|
const canTogglePublish = canManage && !isBuiltin;
|
||||||
|
|
||||||
|
async function handleTogglePublished(next: boolean) {
|
||||||
|
try {
|
||||||
|
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
||||||
|
toast.success(
|
||||||
|
next
|
||||||
|
? t.settings.skills.publishSuccess
|
||||||
|
: t.settings.skills.unpublishSuccess,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteSkill.mutateAsync(skill.name);
|
||||||
|
toast.success(t.settings.skills.deleteSuccess);
|
||||||
|
setConfirmDeleteOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Item className="w-full" variant="outline">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>{skill.name}</span>
|
||||||
|
{isBuiltin && isCustom && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t.settings.skills.badgeBuiltin}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeMine}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isFromOthers && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
|
title={
|
||||||
|
skill.owner_user_id
|
||||||
|
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||||
|
"{user_id}",
|
||||||
|
skill.owner_user_id,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeFromOthers}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && skill.published && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePublishedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgePublished}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="line-clamp-4">
|
||||||
|
{skill.description}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{canTogglePublish && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={setPublished.isPending}
|
||||||
|
onClick={() => void handleTogglePublished(!skill.published)}
|
||||||
|
title={
|
||||||
|
skill.published
|
||||||
|
? t.settings.skills.unpublishAction
|
||||||
|
: t.settings.skills.publishAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.published ? (
|
||||||
|
<EyeOffIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
|
title={t.settings.skills.deleteAction}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={skill.enabled}
|
||||||
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||||
|
onCheckedChange={(checked) => onToggleEnabled(checked)}
|
||||||
|
/>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmDeleteOpen(false)}
|
||||||
|
disabled={deleteSkill.isPending}
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={deleteSkill.isPending}
|
||||||
|
>
|
||||||
|
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
import { SettingsDialog } from "./settings";
|
import { SettingsDialog } from "./settings";
|
||||||
|
|
||||||
function NavMenuButtonContent({
|
function NavMenuButtonContent({
|
||||||
@@ -62,6 +63,9 @@ export function WorkspaceNavMenu() {
|
|||||||
defaultSection={settingsDefaultSection}
|
defaultSection={settingsDefaultSection}
|
||||||
/>
|
/>
|
||||||
<SidebarMenu className="w-full">
|
<SidebarMenu className="w-full">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<NotificationBell />
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -439,6 +439,20 @@ export const enUS: Translations = {
|
|||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||||
emptyButton: "Create Your First Skill",
|
emptyButton: "Create Your First Skill",
|
||||||
|
badgeBuiltin: "Built-in",
|
||||||
|
badgeMine: "Mine",
|
||||||
|
badgeFromOthers: "From others",
|
||||||
|
badgeFromOthersTitle: "Owned by user {user_id}",
|
||||||
|
badgePublished: "Published",
|
||||||
|
badgePublishedTitle: "Visible to all users",
|
||||||
|
publishAction: "Publish",
|
||||||
|
unpublishAction: "Unpublish",
|
||||||
|
publishSuccess: "Skill published",
|
||||||
|
unpublishSuccess: "Skill unpublished",
|
||||||
|
deleteAction: "Delete skill",
|
||||||
|
deleteConfirm:
|
||||||
|
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||||
|
deleteSuccess: "Skill deleted",
|
||||||
installDialog: {
|
installDialog: {
|
||||||
title: "Install skill",
|
title: "Install skill",
|
||||||
dropHint: "Drop a .zip or .skill archive here, or",
|
dropHint: "Drop a .zip or .skill archive here, or",
|
||||||
@@ -539,4 +553,13 @@ export const enUS: Translations = {
|
|||||||
emptyDescription: "Credits and acknowledgements will show here.",
|
emptyDescription: "Credits and acknowledgements will show here.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
title: "Notifications",
|
||||||
|
bellAria: "Open notifications",
|
||||||
|
unreadShort: "{count} unread",
|
||||||
|
markAllRead: "Mark all read",
|
||||||
|
empty: "No notifications",
|
||||||
|
deleteAria: "Delete notification",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface NotificationsTranslations {
|
||||||
|
title: string;
|
||||||
|
bellAria: string;
|
||||||
|
unreadShort: string;
|
||||||
|
markAllRead: string;
|
||||||
|
empty: string;
|
||||||
|
deleteAria: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
@@ -359,6 +368,19 @@ export interface Translations {
|
|||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: string;
|
emptyButton: string;
|
||||||
|
badgeBuiltin: string;
|
||||||
|
badgeMine: string;
|
||||||
|
badgeFromOthers: string;
|
||||||
|
badgeFromOthersTitle: string;
|
||||||
|
badgePublished: string;
|
||||||
|
badgePublishedTitle: string;
|
||||||
|
publishAction: string;
|
||||||
|
unpublishAction: string;
|
||||||
|
publishSuccess: string;
|
||||||
|
unpublishSuccess: string;
|
||||||
|
deleteAction: string;
|
||||||
|
deleteConfirm: string;
|
||||||
|
deleteSuccess: string;
|
||||||
installDialog: {
|
installDialog: {
|
||||||
title: string;
|
title: string;
|
||||||
dropHint: string;
|
dropHint: string;
|
||||||
@@ -446,4 +468,7 @@ export interface Translations {
|
|||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In-app notification inbox (bell in the workspace sidebar)
|
||||||
|
notifications: NotificationsTranslations;
|
||||||
}
|
}
|
||||||
@@ -420,6 +420,19 @@ export const zhCN: Translations = {
|
|||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
emptyButton: "创建你的第一个技能",
|
emptyButton: "创建你的第一个技能",
|
||||||
|
badgeBuiltin: "内置",
|
||||||
|
badgeMine: "我的",
|
||||||
|
badgeFromOthers: "来自他人",
|
||||||
|
badgeFromOthersTitle: "来自用户 {user_id}",
|
||||||
|
badgePublished: "已发布",
|
||||||
|
badgePublishedTitle: "该技能已对所有用户可见",
|
||||||
|
publishAction: "发布",
|
||||||
|
unpublishAction: "取消发布",
|
||||||
|
publishSuccess: "已发布",
|
||||||
|
unpublishSuccess: "已取消发布",
|
||||||
|
deleteAction: "删除技能",
|
||||||
|
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
|
||||||
|
deleteSuccess: "技能已删除",
|
||||||
installDialog: {
|
installDialog: {
|
||||||
title: "安装技能",
|
title: "安装技能",
|
||||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||||
@@ -512,4 +525,13 @@ export const zhCN: Translations = {
|
|||||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
emptyDescription: "相关的致谢信息会展示在这里。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
title: "通知",
|
||||||
|
bellAria: "打开通知",
|
||||||
|
unreadShort: "{count} 条未读",
|
||||||
|
markAllRead: "全部已读",
|
||||||
|
empty: "暂无通知",
|
||||||
|
deleteAria: "删除通知",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NotificationItem,
|
||||||
|
NotificationListResponse,
|
||||||
|
NotificationUnreadCountResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||||
|
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||||
|
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNotifications(
|
||||||
|
options: { unreadOnly?: boolean; limit?: number } = {},
|
||||||
|
): Promise<NotificationItem[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.unreadOnly) params.set("unread_only", "true");
|
||||||
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
|
const query = params.toString();
|
||||||
|
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
|
||||||
|
const res = await apiFetch(url);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
|
||||||
|
const data = (await res.json()) as NotificationListResponse;
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(): Promise<number> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
|
||||||
|
const data = (await res.json()) as NotificationUnreadCountResponse;
|
||||||
|
return data.unread;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<number> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
|
||||||
|
const data = (await res.json()) as { updated: number };
|
||||||
|
return data.updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotification(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteNotification,
|
||||||
|
getUnreadCount,
|
||||||
|
listNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
const ROOT_KEY = ["notifications"] as const;
|
||||||
|
const COUNT_KEY = [...ROOT_KEY, "unread-count"] as const;
|
||||||
|
|
||||||
|
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
|
||||||
|
const { limit = 20, unreadOnly = false } = options;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...ROOT_KEY, "list", { limit, unreadOnly }],
|
||||||
|
queryFn: () => listNotifications({ limit, unreadOnly }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadCount(refetchInterval = 30000) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: COUNT_KEY,
|
||||||
|
queryFn: () => getUnreadCount(),
|
||||||
|
refetchInterval,
|
||||||
|
// Keep the badge fresh when the user comes back to the tab without
|
||||||
|
// waiting for the next interval tick.
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidateNotifications() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ROOT_KEY });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkNotificationRead() {
|
||||||
|
const invalidate = useInvalidateNotifications();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => markNotificationRead(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAllNotificationsRead() {
|
||||||
|
const invalidate = useInvalidateNotifications();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => markAllNotificationsRead(),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteNotification() {
|
||||||
|
const invalidate = useInvalidateNotifications();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => deleteNotification(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
read: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
items: NotificationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationUnreadCountResponse {
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
@@ -110,3 +110,37 @@ export async function installSkillUpload(
|
|||||||
}
|
}
|
||||||
return response.json() as Promise<InstallSkillResponse>;
|
return response.json() as Promise<InstallSkillResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetSkillPublishedResponse {
|
||||||
|
name: string;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillPublished(
|
||||||
|
skillName: string,
|
||||||
|
published: boolean,
|
||||||
|
): Promise<SetSkillPublishedResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ published }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update published state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SetSkillPublishedResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to delete skill");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
import {
|
||||||
|
deleteCustomSkill,
|
||||||
|
enableSkill,
|
||||||
|
installSkillUpload,
|
||||||
|
setSkillPublished,
|
||||||
|
validateSkillUpload,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
import { loadSkills } from ".";
|
import { loadSkills } from ".";
|
||||||
|
|
||||||
@@ -46,3 +52,29 @@ export function useInstallSkillUpload() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetSkillPublished() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
published,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
published: boolean;
|
||||||
|
}) => setSkillPublished(skillName, published),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCustomSkill() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,4 +4,9 @@ export interface Skill {
|
|||||||
category: string;
|
category: string;
|
||||||
license: string;
|
license: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||||
|
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
published?: boolean;
|
||||||
|
builtin?: boolean;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"""Multitenancy tests for the Stage 1-3 skills/notifications work.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ``SkillStore`` per-user visibility, global-name uniqueness, admin override
|
||||||
|
- ``_get_editable_skill_or_raise`` route helper (owner / admin / 403 / 404 paths)
|
||||||
|
- ``cascade_skill_unpublished_or_deleted`` removes skills from referencing
|
||||||
|
agents' ``config.yaml`` and writes notifications via the sender shim
|
||||||
|
- ``NotificationStore`` CRUD + ``unread_count``
|
||||||
|
- Curator per-user isolation — user A's transitions never touch user B's skills
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.gateway.routers.skills import _get_editable_skill_or_raise, _get_owned_skill_or_raise
|
||||||
|
from deerflow.config import paths as paths_module
|
||||||
|
from deerflow.persistence.agents import MemoryAgentStore
|
||||||
|
from deerflow.persistence.notifications import MemoryNotificationStore, set_notification_store
|
||||||
|
from deerflow.persistence.skills import MemorySkillStore
|
||||||
|
from deerflow.skills import curator_log
|
||||||
|
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_paths(tmp_path, monkeypatch):
|
||||||
|
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
||||||
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setattr(paths_module, "_paths", None)
|
||||||
|
yield tmp_path
|
||||||
|
monkeypatch.setattr(paths_module, "_paths", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_store():
|
||||||
|
"""Install a fresh ``MemoryNotificationStore`` as the sender backend.
|
||||||
|
|
||||||
|
Cleared on both setup and teardown so a stale store from a previous test
|
||||||
|
can never leak in through the global ``_store`` singleton in
|
||||||
|
``deerflow.persistence.notifications.sender``.
|
||||||
|
"""
|
||||||
|
store = MemoryNotificationStore()
|
||||||
|
set_notification_store(store)
|
||||||
|
yield store
|
||||||
|
set_notification_store(None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SkillStore: per-user visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_store_visibility_owner_published_and_builtin() -> None:
|
||||||
|
store = MemorySkillStore()
|
||||||
|
|
||||||
|
# Builtin (legacy migrated, no human owner) — visible to everyone.
|
||||||
|
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||||
|
# User A's private skill (not published).
|
||||||
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||||
|
# User B's published skill — visible across users.
|
||||||
|
await store.create({"name": "beta-tool", "owner_user_id": "user-b", "published": True})
|
||||||
|
# User B's private skill — should be invisible to A and C.
|
||||||
|
await store.create({"name": "gamma-tool", "owner_user_id": "user-b", "published": False})
|
||||||
|
|
||||||
|
names_for_a = {s["name"] for s in await store.list_visible("user-a")}
|
||||||
|
assert names_for_a == {"builtin-skill", "alpha-tool", "beta-tool"}
|
||||||
|
|
||||||
|
names_for_c = {s["name"] for s in await store.list_visible("user-c")}
|
||||||
|
assert names_for_c == {"builtin-skill", "beta-tool"}
|
||||||
|
|
||||||
|
# get_visible respects the same filter.
|
||||||
|
assert (await store.get_visible("alpha-tool", "user-a")) is not None
|
||||||
|
assert (await store.get_visible("alpha-tool", "user-c")) is None
|
||||||
|
assert (await store.get_visible("gamma-tool", "user-a")) is None
|
||||||
|
|
||||||
|
# builtin flag is derived from owner_user_id IS NULL.
|
||||||
|
builtin = await store.get_any("builtin-skill")
|
||||||
|
assert builtin is not None and builtin["builtin"] is True
|
||||||
|
alpha = await store.get_any("alpha-tool")
|
||||||
|
assert alpha is not None and alpha["builtin"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_store_create_rejects_duplicate_name() -> None:
|
||||||
|
"""Global uniqueness — even an admin can't register a name a normal user owns."""
|
||||||
|
store = MemorySkillStore()
|
||||||
|
await store.create({"name": "shared", "owner_user_id": "user-a"})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await store.create({"name": "shared", "owner_user_id": "user-b"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_store_only_owner_can_update_or_delete() -> None:
|
||||||
|
store = MemorySkillStore()
|
||||||
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||||
|
|
||||||
|
# Non-owner update returns None and does not mutate.
|
||||||
|
assert await store.update("alpha-tool", "user-b", {"published": True}) is None
|
||||||
|
record = await store.get_any("alpha-tool")
|
||||||
|
assert record is not None and record["published"] is False
|
||||||
|
|
||||||
|
# Owner can update.
|
||||||
|
updated = await store.update("alpha-tool", "user-a", {"published": True})
|
||||||
|
assert updated is not None and updated["published"] is True
|
||||||
|
|
||||||
|
# Non-owner delete fails.
|
||||||
|
assert await store.delete("alpha-tool", "user-b") is False
|
||||||
|
assert await store.get_any("alpha-tool") is not None
|
||||||
|
|
||||||
|
# Owner delete succeeds.
|
||||||
|
assert await store.delete("alpha-tool", "user-a") is True
|
||||||
|
assert await store.get_any("alpha-tool") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_store_admin_override_can_unpublish_and_delete_any_skill() -> None:
|
||||||
|
"""``update_admin`` / ``delete_admin`` bypass ownership — including others' owned skills."""
|
||||||
|
store = MemorySkillStore()
|
||||||
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": True})
|
||||||
|
|
||||||
|
# update_admin can flip published on a skill the admin doesn't own.
|
||||||
|
updated = await store.update_admin("alpha-tool", {"published": False})
|
||||||
|
assert updated is not None and updated["published"] is False
|
||||||
|
|
||||||
|
# delete_admin removes it.
|
||||||
|
assert await store.delete_admin("alpha-tool") is True
|
||||||
|
assert await store.get_any("alpha-tool") is None
|
||||||
|
|
||||||
|
# And no-ops on a missing name.
|
||||||
|
assert await store.update_admin("does-not-exist", {"published": False}) is None
|
||||||
|
assert await store.delete_admin("does-not-exist") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_store_ensure_legacy_patches_null_owner_only() -> None:
|
||||||
|
store = MemorySkillStore()
|
||||||
|
# First migration: no owner → claim by admin.
|
||||||
|
first = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "admin-id", "published": True})
|
||||||
|
assert first["owner_user_id"] == "admin-id"
|
||||||
|
|
||||||
|
# Second migration: someone else tries to take ownership — refused, original wins.
|
||||||
|
second = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "someone-else"})
|
||||||
|
assert second["owner_user_id"] == "admin-id"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route helper authorization (mirrors test_agents_backend.py contract)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_editable_skill_or_raise_owner_admin_and_403() -> None:
|
||||||
|
store = MemorySkillStore()
|
||||||
|
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||||
|
await store.create({"name": "owned", "owner_user_id": "user-a", "published": False})
|
||||||
|
await store.create({"name": "others-published", "owner_user_id": "user-b", "published": True})
|
||||||
|
|
||||||
|
# Owner can edit own skill.
|
||||||
|
record, mode = await _get_editable_skill_or_raise(store, "owned", "user-a", is_admin=False)
|
||||||
|
assert mode == "owner" and record["name"] == "owned"
|
||||||
|
|
||||||
|
# Admin can edit a built-in (NULL owner).
|
||||||
|
record, mode = await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=True)
|
||||||
|
assert mode == "admin" and record["name"] == "builtin-skill"
|
||||||
|
|
||||||
|
# Unlike agents, the spec lets admin take down ANY skill, including
|
||||||
|
# another user's published one.
|
||||||
|
record, mode = await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=True)
|
||||||
|
assert mode == "admin" and record["owner_user_id"] == "user-b"
|
||||||
|
|
||||||
|
# Non-admin trying to edit a built-in → 403.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=False)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
# Non-admin trying to edit someone else's published skill → 403.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=False)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
# Missing skill → 404 regardless of admin.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _get_editable_skill_or_raise(store, "missing", "user-a", is_admin=True)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_owned_skill_or_raise_distinguishes_404_from_403() -> None:
|
||||||
|
"""Visible-but-not-owned must surface as 403 so the user sees a clear reason."""
|
||||||
|
store = MemorySkillStore()
|
||||||
|
await store.create({"name": "published-from-b", "owner_user_id": "user-b", "published": True})
|
||||||
|
|
||||||
|
# Owner check on a published-but-not-owned skill returns 403, not 404.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _get_owned_skill_or_raise(store, "published-from-b", "user-a")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
# Truly invisible / nonexistent → 404.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _get_owned_skill_or_raise(store, "nope", "user-a")
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade: detaches references + writes notifications
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _write_agent_dir(base_dir, *, agent_id: str, skills: list[str]) -> None:
|
||||||
|
agent_dir = base_dir / "agents" / agent_id
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{"id": agent_id, "name": agent_id, "description": "", "skills": skills},
|
||||||
|
default_flow_style=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cascade_unpublish_strips_skill_and_notifies_other_owners(
|
||||||
|
isolated_paths, notification_store
|
||||||
|
) -> None:
|
||||||
|
"""User A unpublishes ``alpha-tool``. User B's agent loses the skill from its
|
||||||
|
config and gets an in-app notification. User A's own agent loses the skill
|
||||||
|
too but no notification (self action)."""
|
||||||
|
agent_store = MemoryAgentStore()
|
||||||
|
# User A is the actor; she also owns one of the referencing agents.
|
||||||
|
await agent_store.create({"id": "agent-of-a", "name": "Agent A", "user_id": "user-a"})
|
||||||
|
await agent_store.create({"id": "agent-of-b", "name": "Agent B", "user_id": "user-b"})
|
||||||
|
# An agent referencing a different skill — should be untouched.
|
||||||
|
await agent_store.create({"id": "agent-untouched", "name": "Untouched", "user_id": "user-b"})
|
||||||
|
|
||||||
|
_write_agent_dir(isolated_paths, agent_id="agent-of-a", skills=["alpha-tool", "other"])
|
||||||
|
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||||
|
_write_agent_dir(isolated_paths, agent_id="agent-untouched", skills=["unrelated"])
|
||||||
|
|
||||||
|
counters = await cascade_skill_unpublished_or_deleted(
|
||||||
|
"alpha-tool",
|
||||||
|
actor_user_id="user-a",
|
||||||
|
operation="unpublish",
|
||||||
|
skill_owner_user_id="user-a",
|
||||||
|
agent_store=agent_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert counters["modified"] == 2
|
||||||
|
assert counters["notified"] == 1 # only user-b — not the actor
|
||||||
|
|
||||||
|
# A's agent: skill stripped, no notification for the actor.
|
||||||
|
a_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-a" / "config.yaml").read_text("utf-8"))
|
||||||
|
assert "alpha-tool" not in a_cfg["skills"]
|
||||||
|
assert "other" in a_cfg["skills"]
|
||||||
|
|
||||||
|
# B's agent: skill stripped, notification delivered to B.
|
||||||
|
b_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-b" / "config.yaml").read_text("utf-8"))
|
||||||
|
assert b_cfg["skills"] == []
|
||||||
|
|
||||||
|
user_b_inbox = await notification_store.list_for_user("user-b")
|
||||||
|
assert len(user_b_inbox) == 1
|
||||||
|
note = user_b_inbox[0]
|
||||||
|
assert note["type"] == "agent_skill_removed"
|
||||||
|
assert note["payload"]["skill_name"] == "alpha-tool"
|
||||||
|
assert note["payload"]["agent_id"] == "agent-of-b"
|
||||||
|
assert note["payload"]["operation"] == "unpublish"
|
||||||
|
|
||||||
|
# Untouched agent retains its config + no notification fired for it.
|
||||||
|
untouched_cfg = yaml.safe_load(
|
||||||
|
(isolated_paths / "agents" / "agent-untouched" / "config.yaml").read_text("utf-8")
|
||||||
|
)
|
||||||
|
assert untouched_cfg["skills"] == ["unrelated"]
|
||||||
|
assert await notification_store.unread_count("user-a") == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cascade_admin_takedown_also_notifies_skill_owner(
|
||||||
|
isolated_paths, notification_store
|
||||||
|
) -> None:
|
||||||
|
"""Admin force-takedown emits an extra ``skill_taken_down`` notification to
|
||||||
|
the original skill owner, on top of any per-agent notifications."""
|
||||||
|
agent_store = MemoryAgentStore()
|
||||||
|
await agent_store.create({"id": "agent-of-b", "name": "B", "user_id": "user-b"})
|
||||||
|
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||||
|
|
||||||
|
await cascade_skill_unpublished_or_deleted(
|
||||||
|
"alpha-tool",
|
||||||
|
actor_user_id="admin-id",
|
||||||
|
operation="admin_takedown",
|
||||||
|
skill_owner_user_id="user-a",
|
||||||
|
agent_store=agent_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
# B (agent owner) gets the agent-skill-removed notification.
|
||||||
|
assert (await notification_store.unread_count("user-b")) == 1
|
||||||
|
# A (skill owner, not the actor) gets the takedown notification.
|
||||||
|
a_inbox = await notification_store.list_for_user("user-a")
|
||||||
|
assert len(a_inbox) == 1
|
||||||
|
assert a_inbox[0]["type"] == "skill_taken_down"
|
||||||
|
assert a_inbox[0]["payload"]["skill_name"] == "alpha-tool"
|
||||||
|
# Admin (the actor) gets nothing.
|
||||||
|
assert (await notification_store.unread_count("admin-id")) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notification inbox CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_store_crud_and_unread_count() -> None:
|
||||||
|
store = MemoryNotificationStore()
|
||||||
|
|
||||||
|
await store.create({"id": "n1", "user_id": "user-a", "type": "x", "title": "t1"})
|
||||||
|
await store.create({"id": "n2", "user_id": "user-a", "type": "x", "title": "t2"})
|
||||||
|
await store.create({"id": "n3", "user_id": "user-b", "type": "x", "title": "t3"})
|
||||||
|
|
||||||
|
assert (await store.unread_count("user-a")) == 2
|
||||||
|
assert (await store.unread_count("user-b")) == 1
|
||||||
|
|
||||||
|
# list_for_user filters by recipient and respects unread_only.
|
||||||
|
a_items = await store.list_for_user("user-a")
|
||||||
|
assert {item["id"] for item in a_items} == {"n1", "n2"}
|
||||||
|
|
||||||
|
# Mark one read → unread count drops; second listing reflects state.
|
||||||
|
assert await store.mark_read("n1", "user-a") is True
|
||||||
|
assert (await store.unread_count("user-a")) == 1
|
||||||
|
unread = await store.list_for_user("user-a", unread_only=True)
|
||||||
|
assert [item["id"] for item in unread] == ["n2"]
|
||||||
|
|
||||||
|
# Cross-user mark_read is rejected.
|
||||||
|
assert await store.mark_read("n3", "user-a") is False
|
||||||
|
assert (await store.unread_count("user-b")) == 1
|
||||||
|
|
||||||
|
# mark_all_read returns the count of newly-flipped rows.
|
||||||
|
flipped = await store.mark_all_read("user-a")
|
||||||
|
assert flipped == 1
|
||||||
|
assert (await store.unread_count("user-a")) == 0
|
||||||
|
|
||||||
|
# Delete behaves the same way — owner-only.
|
||||||
|
assert await store.delete("n3", "user-a") is False
|
||||||
|
assert await store.delete("n3", "user-b") is True
|
||||||
|
assert (await store.list_for_user("user-b")) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Curator per-user isolation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_curator_apply_transitions_only_touches_owned_skills(
|
||||||
|
isolated_paths, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""User A running curator must not mark / archive user B's skills.
|
||||||
|
|
||||||
|
Reproduces a minimal end-of-life scenario: both users own an idle skill,
|
||||||
|
but only A's should transition to STALE.
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from deerflow.skills import curator, usage
|
||||||
|
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
|
||||||
|
|
||||||
|
# The curator's filter helper normally queries the SkillStore via
|
||||||
|
# ``get_session_factory``; here we substitute a static mapping so the test
|
||||||
|
# stays SQL-free. ``apply_automatic_transitions`` is synchronous so we
|
||||||
|
# cannot wait on the async store from inside it without nested-loop pain.
|
||||||
|
owned_by = {
|
||||||
|
"user-a": {"a-skill"},
|
||||||
|
"user-b": {"b-skill"},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: owned_by.get(uid, set()))
|
||||||
|
|
||||||
|
# Point the on-disk skills root at the temp dir so the usage helpers write
|
||||||
|
# to ``{tmp}/skills/.usage.json`` and not into the global singleton state.
|
||||||
|
reset_skill_storage()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.skills.storage._default_skill_storage",
|
||||||
|
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamp both skills as agent-created and old enough to mark stale.
|
||||||
|
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
|
||||||
|
usage._update_entry(
|
||||||
|
"a-skill",
|
||||||
|
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||||
|
)
|
||||||
|
usage._update_entry(
|
||||||
|
"b-skill",
|
||||||
|
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run curator transitions as user-a.
|
||||||
|
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||||
|
|
||||||
|
# Only a-skill counted as checked, only it got transitioned.
|
||||||
|
assert counters["checked"] == 1 # owned-filter narrows the entry set
|
||||||
|
assert counters["archived"] == 1 # 90 days idle == archive_after_days
|
||||||
|
|
||||||
|
# The on-disk state reflects the filter.
|
||||||
|
assert usage.get_entry("a-skill")["state"] == usage.STATE_ARCHIVED
|
||||||
|
assert usage.get_entry("b-skill")["state"] == usage.STATE_ACTIVE
|
||||||
|
|
||||||
|
reset_skill_storage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_curator_log_buckets_records_per_user() -> None:
|
||||||
|
"""Curator log records for one user must not appear in another's view —
|
||||||
|
guards the dict-of-deques refactor in curator_log.py."""
|
||||||
|
curator_log.clear_all()
|
||||||
|
|
||||||
|
curator_log.start_new_run(user_id="user-a")
|
||||||
|
curator_log.record("info", "alpha event", user_id="user-a")
|
||||||
|
|
||||||
|
curator_log.start_new_run(user_id="user-b")
|
||||||
|
curator_log.record("info", "beta event", user_id="user-b")
|
||||||
|
|
||||||
|
a_records = curator_log.get_recent(user_id="user-a")
|
||||||
|
b_records = curator_log.get_recent(user_id="user-b")
|
||||||
|
|
||||||
|
assert [r["message"] for r in a_records] == ["run_started", "alpha event"]
|
||||||
|
assert [r["message"] for r in b_records] == ["run_started", "beta event"]
|
||||||
|
|
||||||
|
# An unknown user gets an empty buffer rather than a cross-user view.
|
||||||
|
assert curator_log.get_recent(user_id="user-c") == []
|
||||||
|
|
||||||
|
curator_log.clear_all()
|
||||||
Reference in new issue
Block a user