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:
xuhdandClaude Opus 4.7 committed 2026-05-14 17:35:02 +08:00
1 parent 4ef96c7930
commit 99febda256
14 files changed
+1133 -21

No files matched your search

@@ -1,6 +1,17 @@
"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 { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -10,6 +21,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -31,11 +43,14 @@ import {
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useEnableSkill,
useInstallSkillUpload,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -80,6 +95,11 @@ function SkillSettingsList({
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
@@ -127,25 +147,15 @@ function SkillSettingsList({
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</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>
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
onToggleEnabled={(enabled) =>
enableSkill({ skillName: skill.name, enabled })
}
/>
))}
<InstallSkillDialog
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 }) {
const { t } = useI18n();
return (