feat(skills): 自我进化 - 软归档/恢复 + Agent 归属 + 状态分层 UI
后端:
- 新增 PUT /api/skills/custom/{name}/state 与 /pinned 软归档/恢复 + 钉选 API
- skill_manage 工具: agent 创建写 DB 归属; 删除清理 .usage.json + DB row + cascade 引用; 加 ownership 预校验
- curator 全部软归档(文件保留在 custom/), 加 reason 必填(中文), 修 user_context 跨线程传播 / 协程泄漏 / archive 文件不再移动
- LLM 评审接 ToolErrorHandlingMiddleware, 工具异常自动重试不中止本轮; 替换 create_react_agent 为 create_agent
- skill_list 按当前 agent config.yaml 的 skills 字段过滤(custom agent), lead agent 仍可见全部
- 自我进化提示词对齐 Hermes 风格: 主动询问保存, reason 强制中文且面向用户
- 控制台日志增强: 自动归档/标过时/LLM 合并/删减建议 都按条目输出原因
前端:
- 技能管理页 Tab 改「全部 / 我的」, 正确包含他人发布的 custom 技能(修复 isCustom 互斥 bug)
- 新增「过时 N / 归档 N」右侧抽屉(恢复/查看/立即归档 ⋯ 菜单)
- 新增徽章: 钉选 / 来源(Agent 创建 / 用户上传) / 使用次数展示
- 内置技能徽章修复(原 isBuiltin && isCustom 永远 false)
- 自我进化日志 run_started sentinel 在前端展示为中文分隔条
- 移除冗余 enabled 开关(归档已覆盖此能力, 减少术语)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f8bc74b27f
commit
62d525dae7
16 files changed
+1258
-85
No files matched your search
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
InfoIcon,
|
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
|
SparklesIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -20,6 +20,7 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
|||||||
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 { 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";
|
||||||
@@ -28,6 +29,7 @@ type SettingsSection =
|
|||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "tools"
|
| "tools"
|
||||||
|
| "skills"
|
||||||
| "notification"
|
| "notification"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
@@ -67,12 +69,14 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
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: "about", label: t.settings.sections.about, icon: InfoIcon },
|
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
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.notification,
|
||||||
t.settings.sections.about,
|
t.settings.sections.about,
|
||||||
],
|
],
|
||||||
@@ -123,6 +127,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||||
|
{activeSection === "skills" && <SkillSettingsPage />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ArchiveIcon,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ClockIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
PinIcon,
|
||||||
|
PinOffIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
@@ -41,15 +45,14 @@ import {
|
|||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
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 { 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,
|
useDeleteCustomSkill,
|
||||||
useEnableSkill,
|
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
@@ -59,6 +62,7 @@ import { env } from "@/env";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
import { SkillStateSheet } from "./skill-state-sheet";
|
||||||
|
|
||||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -88,34 +92,99 @@ function SkillSettingsList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("public");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||||
const filteredSkills = useMemo(
|
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||||
() => skills.filter((skill) => skill.category === filter),
|
|
||||||
[skills, filter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
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.
|
||||||
|
// "All" = built-in + anyone-published-custom + mine (active state only).
|
||||||
|
// "Mine" = strictly my own custom skills (active state only).
|
||||||
|
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||||
|
const all: Skill[] = [];
|
||||||
|
const mine: Skill[] = [];
|
||||||
|
const stale: Skill[] = [];
|
||||||
|
const archived: Skill[] = [];
|
||||||
|
for (const skill of skills) {
|
||||||
|
const state = skill.state ?? "active";
|
||||||
|
const isCustom = skill.category === "custom";
|
||||||
|
const isOwnedByMe =
|
||||||
|
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
|
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||||
|
const isPublishedByOthers =
|
||||||
|
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
|
||||||
|
|
||||||
|
if (state === "stale") {
|
||||||
|
stale.push(skill);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state === "archived") {
|
||||||
|
archived.push(skill);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// state === active
|
||||||
|
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
||||||
|
all.push(skill);
|
||||||
|
}
|
||||||
|
if (isOwnedByMe) {
|
||||||
|
mine.push(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeAll: all,
|
||||||
|
activeMine: mine,
|
||||||
|
staleVisible: stale,
|
||||||
|
archivedVisible: archived,
|
||||||
|
};
|
||||||
|
}, [skills, currentUserId]);
|
||||||
|
|
||||||
|
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||||
|
const staleCount = staleVisible.length;
|
||||||
|
const archivedCount = archivedVisible.length;
|
||||||
|
|
||||||
const handleCreateSkill = () => {
|
const handleCreateSkill = () => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
router.push("/?mode=skill");
|
router.push("/?mode=skill");
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<header className="flex justify-between">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs defaultValue="public" onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||||
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={staleCount === 0}
|
||||||
|
onClick={() => setStaleSheetOpen(true)}
|
||||||
|
title={t.settings.skills.badgeStaleTitle}
|
||||||
|
>
|
||||||
|
<ClockIcon className="size-4" />
|
||||||
|
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={archivedCount === 0}
|
||||||
|
onClick={() => setArchiveSheetOpen(true)}
|
||||||
|
title={t.settings.skills.badgeArchivedTitle}
|
||||||
|
>
|
||||||
|
<ArchiveIcon className="size-4" />
|
||||||
|
{t.settings.skills.entryArchived.replace(
|
||||||
|
"{count}",
|
||||||
|
String(archivedCount),
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -152,9 +221,6 @@ function SkillSettingsList({
|
|||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onToggleEnabled={(enabled) =>
|
|
||||||
enableSkill({ skillName: skill.name, enabled })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<InstallSkillDialog
|
<InstallSkillDialog
|
||||||
@@ -162,6 +228,18 @@ function SkillSettingsList({
|
|||||||
onClose={() => setInstallOpen(false)}
|
onClose={() => setInstallOpen(false)}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
|
<SkillStateSheet
|
||||||
|
kind="stale"
|
||||||
|
open={staleSheetOpen}
|
||||||
|
onOpenChange={setStaleSheetOpen}
|
||||||
|
skills={staleVisible}
|
||||||
|
/>
|
||||||
|
<SkillStateSheet
|
||||||
|
kind="archived"
|
||||||
|
open={archiveSheetOpen}
|
||||||
|
onOpenChange={setArchiveSheetOpen}
|
||||||
|
skills={archivedVisible}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,15 +248,14 @@ function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onToggleEnabled,
|
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onToggleEnabled: (enabled: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
|
const setPinned = useSetSkillPinned();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
|
||||||
@@ -216,6 +293,18 @@ function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTogglePinned() {
|
||||||
|
const next = !skill.pinned;
|
||||||
|
try {
|
||||||
|
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
|
||||||
|
toast.success(
|
||||||
|
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full" variant="outline">
|
<Item className="w-full" variant="outline">
|
||||||
@@ -223,7 +312,7 @@ function SkillRow({
|
|||||||
<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>
|
||||||
{isBuiltin && isCustom && (
|
{isBuiltin && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{t.settings.skills.badgeBuiltin}
|
{t.settings.skills.badgeBuiltin}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -261,13 +350,70 @@ function SkillRow({
|
|||||||
{t.settings.skills.badgePublished}
|
{t.settings.skills.badgePublished}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceAgentTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceAgent}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceUploadTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceUpload}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.pinned && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePinnedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgePinned}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="line-clamp-4">
|
<ItemDescription className="line-clamp-4">
|
||||||
{skill.description}
|
{skill.description}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
|
{skill.use_count !== undefined && (
|
||||||
|
<div className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{skill.use_count > 0
|
||||||
|
? t.settings.skills.useCountInline.replace(
|
||||||
|
"{count}",
|
||||||
|
String(skill.use_count),
|
||||||
|
)
|
||||||
|
: t.settings.skills.useCountInlineZero}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={setPinned.isPending}
|
||||||
|
onClick={() => void handleTogglePinned()}
|
||||||
|
title={
|
||||||
|
skill.pinned
|
||||||
|
? t.settings.skills.unpinAction
|
||||||
|
: t.settings.skills.pinAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.pinned ? (
|
||||||
|
<PinOffIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<PinIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canTogglePublish && (
|
{canTogglePublish && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -299,11 +445,6 @@ function SkillRow({
|
|||||||
<Trash2Icon className="size-3.5" />
|
<Trash2Icon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Switch
|
|
||||||
checked={skill.enabled}
|
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
|
||||||
onCheckedChange={(checked) => onToggleEnabled(checked)}
|
|
||||||
/>
|
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
|
||||||
|
import type { Skill } from "@/core/skills/type";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Kind = "stale" | "archived";
|
||||||
|
|
||||||
|
export function SkillStateSheet({
|
||||||
|
kind,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
skills,
|
||||||
|
}: {
|
||||||
|
kind: Kind;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
skills: Skill[];
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tt = t.settings.skills;
|
||||||
|
const ts = tt.stateSheet;
|
||||||
|
const isArchived = kind === "archived";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
|
||||||
|
<SheetHeader className="border-b">
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
{isArchived ? (
|
||||||
|
<ArchiveIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ClockIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{isArchived ? ts.archivedTitle : ts.staleTitle}
|
||||||
|
<span className="text-muted-foreground text-sm font-normal">
|
||||||
|
({skills.length})
|
||||||
|
</span>
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{isArchived ? ts.archivedDescription : ts.staleDescription}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{ts.empty}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tt = t.settings.skills;
|
||||||
|
const ts = tt.stateSheet;
|
||||||
|
const setState = useSetSkillState();
|
||||||
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
|
||||||
|
const pending = setState.isPending || deleteSkill.isPending;
|
||||||
|
const isArchived = kind === "archived";
|
||||||
|
|
||||||
|
const timeLabel = useMemo(() => {
|
||||||
|
if (isArchived) {
|
||||||
|
const when = skill.last_activity_at;
|
||||||
|
return when
|
||||||
|
? ts.archivedAt.replace("{when}", formatRelative(when))
|
||||||
|
: ts.archivedAt.replace("{when}", "—");
|
||||||
|
}
|
||||||
|
const when = skill.last_activity_at;
|
||||||
|
return when
|
||||||
|
? ts.lastUsedAt.replace("{when}", formatRelative(when))
|
||||||
|
: ts.neverUsed;
|
||||||
|
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
|
||||||
|
|
||||||
|
async function handleRestore() {
|
||||||
|
try {
|
||||||
|
await setState.mutateAsync({ skillName: skill.name, state: "active" });
|
||||||
|
toast.success(ts.restoreSuccess);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
try {
|
||||||
|
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
|
||||||
|
toast.success(ts.archiveSuccess);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteSkill.mutateAsync(skill.name);
|
||||||
|
toast.success(tt.deleteSuccess);
|
||||||
|
setConfirmDeleteOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{skill.name}</span>
|
||||||
|
{isArchived ? (
|
||||||
|
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
|
||||||
|
{tt.badgeArchived}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||||
|
title={tt.badgeStaleTitle}
|
||||||
|
>
|
||||||
|
{tt.badgeStale}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.source === "agent" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||||
|
title={tt.badgeSourceAgentTitle}
|
||||||
|
>
|
||||||
|
{tt.badgeSourceAgent}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.source === "upload" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||||
|
title={tt.badgeSourceUploadTitle}
|
||||||
|
>
|
||||||
|
{tt.badgeSourceUpload}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||||
|
<span>{timeLabel}</span>
|
||||||
|
<span>
|
||||||
|
{skill.use_count && skill.use_count > 0
|
||||||
|
? tt.useCountInline.replace(
|
||||||
|
"{count}",
|
||||||
|
String(skill.use_count),
|
||||||
|
)
|
||||||
|
: tt.useCountInlineZero}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleRestore()}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon className="size-3.5" />
|
||||||
|
{ts.actionRestore}
|
||||||
|
</Button>
|
||||||
|
{!isArchived && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleArchive()}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<ArchiveIcon className="size-3.5" />
|
||||||
|
{ts.actionArchiveNow}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={pending}
|
||||||
|
title={ts.moreMenu}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
|
||||||
|
{ts.actionView}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setConfirmDeleteOpen(true)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
{ts.actionDelete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SkillViewDialog
|
||||||
|
skill={skill}
|
||||||
|
open={viewOpen}
|
||||||
|
onOpenChange={setViewOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{tt.deleteAction}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{tt.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 SkillViewDialog({
|
||||||
|
skill,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
skill: Skill;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tv = t.settings.skills.viewDialog;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{skill.name}</DialogTitle>
|
||||||
|
<DialogDescription>{tv.title}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 text-sm">
|
||||||
|
<Row label={tv.descriptionLabel}>
|
||||||
|
<span className="whitespace-pre-wrap">{skill.description}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={tv.stateLabel}>
|
||||||
|
<span className={cn("font-medium", stateColor(skill.state))}>
|
||||||
|
{skill.state ?? "active"}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
{skill.use_count !== undefined && (
|
||||||
|
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
|
||||||
|
)}
|
||||||
|
{skill.last_activity_at && (
|
||||||
|
<Row label={tv.lastUsedLabel}>
|
||||||
|
{formatRelative(skill.last_activity_at)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{tv.close}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||||||
|
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateColor(state: string | undefined): string {
|
||||||
|
switch (state) {
|
||||||
|
case "archived":
|
||||||
|
return "text-muted-foreground";
|
||||||
|
case "stale":
|
||||||
|
return "text-amber-600 dark:text-amber-300";
|
||||||
|
default:
|
||||||
|
return "text-emerald-600 dark:text-emerald-300";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(ms)) return iso;
|
||||||
|
const sec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const hour = Math.floor(min / 60);
|
||||||
|
const day = Math.floor(hour / 24);
|
||||||
|
if (day >= 1) return `${day}d ago`;
|
||||||
|
if (hour >= 1) return `${hour}h ago`;
|
||||||
|
if (min >= 1) return `${min}m ago`;
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
@@ -432,7 +432,9 @@ function LogsCard() {
|
|||||||
{record.level}
|
{record.level}
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-pre-wrap break-all">
|
<span className="whitespace-pre-wrap break-all">
|
||||||
{record.message}
|
{record.message === "run_started"
|
||||||
|
? "——— 本次策展开始 ———"
|
||||||
|
: record.message}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||||
|
|
||||||
export function WorkspaceNavChatList() {
|
export function WorkspaceNavChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -446,14 +446,66 @@ export const enUS: Translations = {
|
|||||||
badgeFromOthersTitle: "Owned by user {user_id}",
|
badgeFromOthersTitle: "Owned by user {user_id}",
|
||||||
badgePublished: "Published",
|
badgePublished: "Published",
|
||||||
badgePublishedTitle: "Visible to all users",
|
badgePublishedTitle: "Visible to all users",
|
||||||
|
badgeStale: "Stale",
|
||||||
|
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
||||||
|
badgeArchived: "Archived",
|
||||||
|
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||||
|
badgePinned: "Pinned",
|
||||||
|
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||||
|
badgeSourceAgent: "Agent-created",
|
||||||
|
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||||
|
badgeSourceUpload: "Uploaded",
|
||||||
|
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||||
|
tabAll: "All",
|
||||||
|
tabMine: "Mine",
|
||||||
|
entryStale: "Stale {count}",
|
||||||
|
entryArchived: "Archived {count}",
|
||||||
|
useCountInline: "Used {count} times",
|
||||||
|
useCountInlineZero: "Not used yet",
|
||||||
publishAction: "Publish",
|
publishAction: "Publish",
|
||||||
unpublishAction: "Unpublish",
|
unpublishAction: "Unpublish",
|
||||||
publishSuccess: "Skill published",
|
publishSuccess: "Skill published",
|
||||||
unpublishSuccess: "Skill unpublished",
|
unpublishSuccess: "Skill unpublished",
|
||||||
|
pinAction: "Pin (prevent auto-archive)",
|
||||||
|
unpinAction: "Unpin",
|
||||||
|
pinSuccess: "Pinned",
|
||||||
|
unpinSuccess: "Unpinned",
|
||||||
deleteAction: "Delete skill",
|
deleteAction: "Delete skill",
|
||||||
deleteConfirm:
|
deleteConfirm:
|
||||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||||
deleteSuccess: "Skill deleted",
|
deleteSuccess: "Skill deleted",
|
||||||
|
stateSheet: {
|
||||||
|
archivedTitle: "Archived skills",
|
||||||
|
archivedDescription:
|
||||||
|
"These skills have been archived by you or the curator and can be restored at any time.",
|
||||||
|
staleTitle: "Stale skills",
|
||||||
|
staleDescription:
|
||||||
|
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
|
||||||
|
empty: "Nothing here",
|
||||||
|
actionRestore: "Restore",
|
||||||
|
actionArchiveNow: "Archive now",
|
||||||
|
actionView: "View",
|
||||||
|
actionPin: "Pin",
|
||||||
|
actionUnpin: "Unpin",
|
||||||
|
actionDelete: "Delete permanently",
|
||||||
|
moreMenu: "More",
|
||||||
|
archivedAt: "Archived {when}",
|
||||||
|
lastUsedAt: "Last used {when}",
|
||||||
|
neverUsed: "Never used",
|
||||||
|
restoreSuccess: "Restored",
|
||||||
|
archiveSuccess: "Archived",
|
||||||
|
pinSuccess: "Pinned",
|
||||||
|
unpinSuccess: "Unpinned",
|
||||||
|
},
|
||||||
|
viewDialog: {
|
||||||
|
title: "Skill details",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
stateLabel: "State",
|
||||||
|
sourceLabel: "Source",
|
||||||
|
useCountLabel: "Use count",
|
||||||
|
lastUsedLabel: "Last used",
|
||||||
|
close: "Close",
|
||||||
|
},
|
||||||
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",
|
||||||
|
|||||||
@@ -375,13 +375,63 @@ export interface Translations {
|
|||||||
badgeFromOthersTitle: string;
|
badgeFromOthersTitle: string;
|
||||||
badgePublished: string;
|
badgePublished: string;
|
||||||
badgePublishedTitle: string;
|
badgePublishedTitle: string;
|
||||||
|
badgeStale: string;
|
||||||
|
badgeStaleTitle: string;
|
||||||
|
badgeArchived: string;
|
||||||
|
badgeArchivedTitle: string;
|
||||||
|
badgePinned: string;
|
||||||
|
badgePinnedTitle: string;
|
||||||
|
badgeSourceAgent: string;
|
||||||
|
badgeSourceAgentTitle: string;
|
||||||
|
badgeSourceUpload: string;
|
||||||
|
badgeSourceUploadTitle: string;
|
||||||
|
tabAll: string;
|
||||||
|
tabMine: string;
|
||||||
|
entryStale: string;
|
||||||
|
entryArchived: string;
|
||||||
|
useCountInline: string;
|
||||||
|
useCountInlineZero: string;
|
||||||
publishAction: string;
|
publishAction: string;
|
||||||
unpublishAction: string;
|
unpublishAction: string;
|
||||||
publishSuccess: string;
|
publishSuccess: string;
|
||||||
unpublishSuccess: string;
|
unpublishSuccess: string;
|
||||||
|
pinAction: string;
|
||||||
|
unpinAction: string;
|
||||||
|
pinSuccess: string;
|
||||||
|
unpinSuccess: string;
|
||||||
deleteAction: string;
|
deleteAction: string;
|
||||||
deleteConfirm: string;
|
deleteConfirm: string;
|
||||||
deleteSuccess: string;
|
deleteSuccess: string;
|
||||||
|
stateSheet: {
|
||||||
|
archivedTitle: string;
|
||||||
|
archivedDescription: string;
|
||||||
|
staleTitle: string;
|
||||||
|
staleDescription: string;
|
||||||
|
empty: string;
|
||||||
|
actionRestore: string;
|
||||||
|
actionArchiveNow: string;
|
||||||
|
actionView: string;
|
||||||
|
actionPin: string;
|
||||||
|
actionUnpin: string;
|
||||||
|
actionDelete: string;
|
||||||
|
moreMenu: string;
|
||||||
|
archivedAt: string;
|
||||||
|
lastUsedAt: string;
|
||||||
|
neverUsed: string;
|
||||||
|
restoreSuccess: string;
|
||||||
|
archiveSuccess: string;
|
||||||
|
pinSuccess: string;
|
||||||
|
unpinSuccess: string;
|
||||||
|
};
|
||||||
|
viewDialog: {
|
||||||
|
title: string;
|
||||||
|
descriptionLabel: string;
|
||||||
|
stateLabel: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
useCountLabel: string;
|
||||||
|
lastUsedLabel: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
installDialog: {
|
installDialog: {
|
||||||
title: string;
|
title: string;
|
||||||
dropHint: string;
|
dropHint: string;
|
||||||
|
|||||||
@@ -427,13 +427,63 @@ export const zhCN: Translations = {
|
|||||||
badgeFromOthersTitle: "来自用户 {user_id}",
|
badgeFromOthersTitle: "来自用户 {user_id}",
|
||||||
badgePublished: "已发布",
|
badgePublished: "已发布",
|
||||||
badgePublishedTitle: "该技能已对所有用户可见",
|
badgePublishedTitle: "该技能已对所有用户可见",
|
||||||
|
badgeStale: "过时",
|
||||||
|
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||||
|
badgeArchived: "归档",
|
||||||
|
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||||
|
badgePinned: "钉选",
|
||||||
|
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||||
|
badgeSourceAgent: "Agent 创建",
|
||||||
|
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||||
|
badgeSourceUpload: "用户上传",
|
||||||
|
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||||
|
tabAll: "全部",
|
||||||
|
tabMine: "我的",
|
||||||
|
entryStale: "过时 {count}",
|
||||||
|
entryArchived: "归档 {count}",
|
||||||
|
useCountInline: "调用 {count} 次",
|
||||||
|
useCountInlineZero: "尚未调用",
|
||||||
publishAction: "发布",
|
publishAction: "发布",
|
||||||
unpublishAction: "取消发布",
|
unpublishAction: "取消发布",
|
||||||
publishSuccess: "已发布",
|
publishSuccess: "已发布",
|
||||||
unpublishSuccess: "已取消发布",
|
unpublishSuccess: "已取消发布",
|
||||||
|
pinAction: "钉选(防止被自动归档)",
|
||||||
|
unpinAction: "取消钉选",
|
||||||
|
pinSuccess: "已钉选",
|
||||||
|
unpinSuccess: "已取消钉选",
|
||||||
deleteAction: "删除技能",
|
deleteAction: "删除技能",
|
||||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
|
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
|
||||||
deleteSuccess: "技能已删除",
|
deleteSuccess: "技能已删除",
|
||||||
|
stateSheet: {
|
||||||
|
archivedTitle: "归档的技能",
|
||||||
|
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
|
||||||
|
staleTitle: "过时的技能",
|
||||||
|
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
|
||||||
|
empty: "暂无内容",
|
||||||
|
actionRestore: "恢复",
|
||||||
|
actionArchiveNow: "立即归档",
|
||||||
|
actionView: "查看详情",
|
||||||
|
actionPin: "钉选",
|
||||||
|
actionUnpin: "取消钉选",
|
||||||
|
actionDelete: "永久删除",
|
||||||
|
moreMenu: "更多操作",
|
||||||
|
archivedAt: "归档于 {when}",
|
||||||
|
lastUsedAt: "最近使用 {when}",
|
||||||
|
neverUsed: "尚未使用",
|
||||||
|
restoreSuccess: "已恢复",
|
||||||
|
archiveSuccess: "已归档",
|
||||||
|
pinSuccess: "已钉选",
|
||||||
|
unpinSuccess: "已取消钉选",
|
||||||
|
},
|
||||||
|
viewDialog: {
|
||||||
|
title: "技能详情",
|
||||||
|
descriptionLabel: "描述",
|
||||||
|
stateLabel: "状态",
|
||||||
|
sourceLabel: "来源",
|
||||||
|
useCountLabel: "调用次数",
|
||||||
|
lastUsedLabel: "最近使用",
|
||||||
|
close: "关闭",
|
||||||
|
},
|
||||||
installDialog: {
|
installDialog: {
|
||||||
title: "安装技能",
|
title: "安装技能",
|
||||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
|
||||||
import type { Skill } from "./type";
|
import type { Skill, SkillState } from "./type";
|
||||||
|
|
||||||
export async function loadSkills() {
|
export async function loadSkills() {
|
||||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||||
@@ -138,6 +138,42 @@ export async function setSkillPublished(
|
|||||||
return response.json() as Promise<SetSkillPublishedResponse>;
|
return response.json() as Promise<SetSkillPublishedResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setSkillState(
|
||||||
|
skillName: string,
|
||||||
|
state: SkillState,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillPinned(
|
||||||
|
skillName: string,
|
||||||
|
pinned: boolean,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pinned }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill pinned state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
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)}`,
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
|
setSkillState,
|
||||||
validateSkillUpload,
|
validateSkillUpload,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import type { SkillState } from "./type";
|
||||||
|
|
||||||
import { loadSkills } from ".";
|
import { loadSkills } from ".";
|
||||||
|
|
||||||
@@ -69,6 +72,28 @@ export function useSetSkillPublished() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetSkillState() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
|
||||||
|
setSkillState(skillName, state),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillPinned() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
|
||||||
|
setSkillPinned(skillName, pinned),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteCustomSkill() {
|
export function useDeleteCustomSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export type SkillState = "active" | "stale" | "archived";
|
||||||
|
export type SkillSource = "agent" | "upload";
|
||||||
|
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -9,4 +12,15 @@ export interface Skill {
|
|||||||
owner_user_id?: string | null;
|
owner_user_id?: string | null;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
builtin?: boolean;
|
builtin?: boolean;
|
||||||
|
// Lifecycle fields — populated by curator. The main list filters on these
|
||||||
|
// so stale / archived skills appear in dedicated drawers instead.
|
||||||
|
state?: SkillState;
|
||||||
|
pinned?: boolean;
|
||||||
|
// Provenance — distinguishes skills created by an agent during a chat
|
||||||
|
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||||
|
source?: SkillSource;
|
||||||
|
last_activity_at?: string | null;
|
||||||
|
use_count?: number;
|
||||||
|
view_count?: number;
|
||||||
|
patch_count?: number;
|
||||||
}
|
}
|
||||||
@@ -641,6 +641,96 @@ async def update_custom_skill_published(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_USER_STATES = {"active", "archived"}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillStateRequest(BaseModel):
|
||||||
|
"""Request body for switching a custom skill's lifecycle state."""
|
||||||
|
|
||||||
|
state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillPinRequest(BaseModel):
|
||||||
|
"""Request body for pinning / unpinning a custom skill."""
|
||||||
|
|
||||||
|
pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill")
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse:
|
||||||
|
"""Reload a single skill from disk and return its API response form."""
|
||||||
|
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 on disk")
|
||||||
|
return _skill_to_response(skill, ownership)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/skills/custom/{skill_name}/state",
|
||||||
|
response_model=SkillResponse,
|
||||||
|
summary="Update Custom Skill Lifecycle State",
|
||||||
|
description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.",
|
||||||
|
)
|
||||||
|
async def update_custom_skill_state(
|
||||||
|
skill_name: str,
|
||||||
|
body: SkillStateRequest,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
if body.state not in _ALLOWED_USER_STATES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}",
|
||||||
|
)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
|
||||||
|
from deerflow.skills.usage import set_state as _usage_set_state
|
||||||
|
|
||||||
|
_usage_set_state(skill_name, body.state)
|
||||||
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
return _reload_skill_response(skill_name, record, config)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update state: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/skills/custom/{skill_name}/pinned",
|
||||||
|
response_model=SkillResponse,
|
||||||
|
summary="Pin or Unpin a Custom Skill",
|
||||||
|
description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.",
|
||||||
|
)
|
||||||
|
async def update_custom_skill_pinned(
|
||||||
|
skill_name: str,
|
||||||
|
body: SkillPinRequest,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
|
||||||
|
from deerflow.skills.usage import set_pinned as _usage_set_pinned
|
||||||
|
|
||||||
|
_usage_set_pinned(skill_name, body.pinned)
|
||||||
|
return _reload_skill_response(skill_name, record, config)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+17
-8
@@ -191,14 +191,23 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
|
|||||||
return ""
|
return ""
|
||||||
return """
|
return """
|
||||||
## Skill Self-Evolution
|
## Skill Self-Evolution
|
||||||
After completing a task, consider creating or updating a skill when:
|
|
||||||
- The task required 5+ tool calls to resolve
|
You have access to `skill_manage` (plus `skill_list` / `skill_view`).
|
||||||
- You overcame non-obvious errors or pitfalls
|
|
||||||
- The user corrected your approach and the corrected version worked
|
After completing a non-trivial task — 5+ tool calls to resolve, recovery from
|
||||||
- You discovered a non-trivial, recurring workflow
|
a non-obvious error, a corrected approach that worked, or a recurring
|
||||||
If you used a skill and encountered issues not covered by it, patch it immediately.
|
workflow — **offer to save the approach as a reusable skill** so you can
|
||||||
Prefer patch over edit. Before creating a new skill, confirm with the user first.
|
replay it next time. Briefly propose a hyphen-case name and a one-line
|
||||||
Skip simple one-off tasks.
|
description, then ask the user to confirm before calling
|
||||||
|
`skill_manage(action="create", ...)`. Capture the generalised steps, not the
|
||||||
|
user's specific data, paths, or credentials.
|
||||||
|
|
||||||
|
When using an existing skill and finding it outdated, incomplete, or wrong,
|
||||||
|
**patch it immediately** with `skill_manage(action="patch")` — don't wait to
|
||||||
|
be asked. Skills that aren't maintained become liabilities.
|
||||||
|
|
||||||
|
Skip the offer for one-off queries, casual chat, or short single-tool
|
||||||
|
answers — and never create a skill silently without user confirmation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,70 @@ memory.json and threads/ dirs).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Coroutine
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CURATOR_STATE_FILENAME = ".curator_state"
|
_CURATOR_STATE_FILENAME = ".curator_state"
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_async_blocking(coro: Coroutine[Any, Any, _T]) -> _T:
|
||||||
|
"""Run an async coroutine to completion from sync curator code.
|
||||||
|
|
||||||
|
Curator is invoked from a background thread, but ``agent.invoke()`` inside
|
||||||
|
``_run_llm_review`` (LangChain 1.x ``create_agent``) spins up its own
|
||||||
|
asyncio loop which can leave the calling thread in an "already has a
|
||||||
|
running loop" state. In that case raw ``asyncio.run(coro)`` raises
|
||||||
|
``RuntimeError`` *after* the coroutine object has been materialised but
|
||||||
|
before anyone awaits it — producing the ``coroutine was never awaited``
|
||||||
|
RuntimeWarning that pollutes our logs.
|
||||||
|
|
||||||
|
This helper detects a running loop in the caller's thread and offloads
|
||||||
|
to a dedicated worker thread; otherwise it falls back to ``asyncio.run``.
|
||||||
|
Either way it guarantees the coroutine is closed on the failure path,
|
||||||
|
so no RuntimeWarning ever escapes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
in_loop = True
|
||||||
|
except RuntimeError:
|
||||||
|
in_loop = False
|
||||||
|
|
||||||
|
if in_loop:
|
||||||
|
try:
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
return pool.submit(asyncio.run, coro).result()
|
||||||
|
except BaseException:
|
||||||
|
# ``asyncio.run`` ran in the worker (it consumes the coroutine on
|
||||||
|
# success or surfaces the inner exception); but if the executor
|
||||||
|
# itself failed to submit, close to be safe.
|
||||||
|
try:
|
||||||
|
coro.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(coro)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
coro.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _get_skills_root() -> Path:
|
def _get_skills_root() -> Path:
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
@@ -102,7 +153,7 @@ def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
|
|||||||
async def _run() -> list[dict]:
|
async def _run() -> list[dict]:
|
||||||
return await store.list_visible(user_id)
|
return await store.list_visible(user_id)
|
||||||
|
|
||||||
records = asyncio.run(_run())
|
records = _run_async_blocking(_run())
|
||||||
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("_list_user_owned_skill_names failed", exc_info=True)
|
logger.debug("_list_user_owned_skill_names failed", exc_info=True)
|
||||||
@@ -173,7 +224,7 @@ def _is_skill_published(skill_name: str) -> bool:
|
|||||||
async def _run() -> dict | None:
|
async def _run() -> dict | None:
|
||||||
return await store.get_any(skill_name)
|
return await store.get_any(skill_name)
|
||||||
|
|
||||||
record = asyncio.run(_run())
|
record = _run_async_blocking(_run())
|
||||||
return bool(record and record.get("published", False))
|
return bool(record and record.get("published", False))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("_is_skill_published(%s) failed", skill_name, exc_info=True)
|
logger.debug("_is_skill_published(%s) failed", skill_name, exc_info=True)
|
||||||
@@ -276,6 +327,8 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
|
|||||||
"skipped_published": skipped_published,
|
"skipped_published": skipped_published,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from deerflow.skills import curator_log
|
||||||
|
|
||||||
for name, entry in eligible.items():
|
for name, entry in eligible.items():
|
||||||
if entry.get("pinned"):
|
if entry.get("pinned"):
|
||||||
continue
|
continue
|
||||||
@@ -295,9 +348,19 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
|
|||||||
if days_idle >= archive_days and current_state != STATE_ARCHIVED:
|
if days_idle >= archive_days and current_state != STATE_ARCHIVED:
|
||||||
set_state(name, STATE_ARCHIVED)
|
set_state(name, STATE_ARCHIVED)
|
||||||
counters["archived"] += 1
|
counters["archived"] += 1
|
||||||
|
curator_log.record(
|
||||||
|
"info",
|
||||||
|
f" 自动归档 [{name}]:已闲置 {days_idle} 天(阈值 {archive_days} 天)",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
elif days_idle >= stale_days and current_state == STATE_ACTIVE:
|
elif days_idle >= stale_days and current_state == STATE_ACTIVE:
|
||||||
set_state(name, STATE_STALE)
|
set_state(name, STATE_STALE)
|
||||||
counters["marked_stale"] += 1
|
counters["marked_stale"] += 1
|
||||||
|
curator_log.record(
|
||||||
|
"info",
|
||||||
|
f" 标记过时 [{name}]:已闲置 {days_idle} 天(阈值 {stale_days} 天)",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
@@ -351,9 +414,19 @@ _CURATOR_REVIEW_PROMPT = (
|
|||||||
"Hard rules — do not violate:\n"
|
"Hard rules — do not violate:\n"
|
||||||
"1. DO NOT touch public (built-in) skills. The candidate list below "
|
"1. DO NOT touch public (built-in) skills. The candidate list below "
|
||||||
"is already filtered to agent-created custom skills only.\n"
|
"is already filtered to agent-created custom skills only.\n"
|
||||||
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
|
"2. DO NOT delete any skill. Archiving (a soft state flip via the "
|
||||||
"into the .archive/ folder under the skills root) is the maximum "
|
"skill_archive tool — the file stays in custom/<name>/ and the user "
|
||||||
"destructive action. Archives are recoverable; deletion is not.\n"
|
"can restore it from the Web UI) is the maximum action you may take. "
|
||||||
|
"Archives are recoverable; deletion is not. "
|
||||||
|
"When you call skill_archive(name, reason) you MUST pass a `reason` "
|
||||||
|
"string written **in Chinese**, in plain language a human operator "
|
||||||
|
"would read — NOT English jargon or terse debug notes. Examples of "
|
||||||
|
"acceptable reasons: '已合并到 code-formatter,该技能同时覆盖 Python 与 JS', "
|
||||||
|
"'与 foo-bar 内容重复,保留 foo-bar', '重构后过时,被 api-v2-search 替代', "
|
||||||
|
"'一次性 POC 实验,无复用价值'. Bad reasons (do NOT use): 'merged', "
|
||||||
|
"'duplicate', 'obsolete', 'consolidated'. The reason is shown in the "
|
||||||
|
"Web UI run-history for user audit, so write it as you would explain "
|
||||||
|
"it to the user themselves.\n"
|
||||||
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
|
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
|
||||||
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
||||||
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
||||||
@@ -425,8 +498,8 @@ _CURATOR_REVIEW_PROMPT = (
|
|||||||
" - name: <skill-name>\n"
|
" - name: <skill-name>\n"
|
||||||
" reason: <one short sentence — why archived with no merge target>\n"
|
" reason: <one short sentence — why archived with no merge target>\n"
|
||||||
"```\n\n"
|
"```\n\n"
|
||||||
"Every skill you moved to .archive/ MUST appear in exactly one of the "
|
"Every skill you archived via skill_archive MUST appear in exactly one "
|
||||||
"two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
"of the two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
||||||
"a references file to Y, or created Y with X's content absorbed), X "
|
"a references file to Y, or created Y with X's content absorbed), X "
|
||||||
"goes under `consolidations` with `into: Y`. If you archived X with "
|
"goes under `consolidations` with `into: Y`. If you archived X with "
|
||||||
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
|
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
|
||||||
@@ -437,28 +510,48 @@ _CURATOR_REVIEW_PROMPT = (
|
|||||||
|
|
||||||
|
|
||||||
def _make_skill_archive_tool(user_id: str):
|
def _make_skill_archive_tool(user_id: str):
|
||||||
"""Curator-only tool: move a skill directory to .archive/ (recoverable)."""
|
"""Curator-only tool: soft-archive a skill (sets state, files stay put)."""
|
||||||
from langchain.tools import tool as lc_tool
|
from langchain.tools import tool as lc_tool
|
||||||
|
|
||||||
from deerflow.skills import curator_log
|
from deerflow.skills import curator_log
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
|
|
||||||
@lc_tool("skill_archive")
|
@lc_tool("skill_archive")
|
||||||
def _skill_archive(name: str) -> str:
|
def _skill_archive(name: str, reason: str) -> str:
|
||||||
"""Move an agent-created skill to the .archive/ directory (recoverable). Use this instead of delete.
|
"""Soft-archive an agent-created skill (sets state=archived; files stay in custom/).
|
||||||
|
|
||||||
|
The user can recover the skill from the Web UI's archive drawer.
|
||||||
|
This is intentionally non-destructive — the on-disk SKILL.md and any
|
||||||
|
supporting files are preserved so nothing is lost.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The skill name to archive.
|
name: The skill name to archive.
|
||||||
|
reason: 必填。用**中文**写一句话,清晰说明为什么归档这个技能 —
|
||||||
|
直接给读它的运维人员看,不要写英文术语 / 调试信息。例如:
|
||||||
|
- "已合并到 code-formatter,该技能同时覆盖 Python 与 JS"
|
||||||
|
- "重构后过时,被 api-v2-search 替代"
|
||||||
|
- "与现有 foo-bar 技能重复,内容完全一致"
|
||||||
|
- "一次性 POC 实验,无复用价值"
|
||||||
|
- "命名过窄(含具体 issue 号),已并入通用伞技能 X"
|
||||||
|
此原因会展示在策展日志和 Web UI 运行历史中,供用户审计。
|
||||||
"""
|
"""
|
||||||
from deerflow.skills.usage import STATE_ARCHIVED, get_entry, set_state
|
from deerflow.skills.usage import STATE_ARCHIVED, get_entry, set_state
|
||||||
|
|
||||||
|
# Defensive: tolerate empty/None reason but log a clear placeholder
|
||||||
|
# rather than crashing the curator run.
|
||||||
|
reason_text = (reason or "").strip() or "未提供原因"
|
||||||
|
|
||||||
storage = get_or_new_skill_storage()
|
storage = get_or_new_skill_storage()
|
||||||
if not storage.custom_skill_exists(name):
|
if not storage.custom_skill_exists(name):
|
||||||
curator_log.record("warning", f"skill_archive: '{name}' not found or already archived", user_id=user_id)
|
curator_log.record("warning", f"归档:未找到技能 [{name}],或它已被归档", user_id=user_id)
|
||||||
return f"Skill '{name}' not found or already archived."
|
return f"Skill '{name}' not found or already archived."
|
||||||
if get_entry(name).get("pinned"):
|
entry = get_entry(name)
|
||||||
curator_log.record("info", f"skill_archive: '{name}' is pinned — skipping", user_id=user_id)
|
if entry.get("pinned"):
|
||||||
|
curator_log.record("info", f"归档:技能 [{name}] 已被钉选,跳过", user_id=user_id)
|
||||||
return f"Skill '{name}' is pinned — skipping."
|
return f"Skill '{name}' is pinned — skipping."
|
||||||
|
if entry.get("state") == STATE_ARCHIVED:
|
||||||
|
curator_log.record("info", f"归档:技能 [{name}] 已是归档状态,跳过", user_id=user_id)
|
||||||
|
return f"Skill '{name}' is already archived."
|
||||||
|
|
||||||
# Safety net: even if the LLM hallucinated a name not in the
|
# Safety net: even if the LLM hallucinated a name not in the
|
||||||
# candidate list, refuse to archive anything that is published or
|
# candidate list, refuse to archive anything that is published or
|
||||||
@@ -466,7 +559,7 @@ def _make_skill_archive_tool(user_id: str):
|
|||||||
if _is_skill_published(name):
|
if _is_skill_published(name):
|
||||||
curator_log.record(
|
curator_log.record(
|
||||||
"warning",
|
"warning",
|
||||||
f"skill_archive: '{name}' is published — refused (curator must not touch published skills)",
|
f"归档:已拒绝处理 [{name}],该技能已发布(策展不允许操作已发布的技能)",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
return f"Refused: skill '{name}' is published; manual unpublish is required first."
|
return f"Refused: skill '{name}' is published; manual unpublish is required first."
|
||||||
@@ -474,24 +567,25 @@ def _make_skill_archive_tool(user_id: str):
|
|||||||
if name in referenced:
|
if name in referenced:
|
||||||
curator_log.record(
|
curator_log.record(
|
||||||
"warning",
|
"warning",
|
||||||
f"skill_archive: '{name}' is referenced by an agent's skills config — refused",
|
f"归档:已拒绝处理 [{name}],该技能正被某个 Agent 引用",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
return f"Refused: skill '{name}' is still referenced by one or more agents."
|
return f"Refused: skill '{name}' is still referenced by one or more agents."
|
||||||
|
|
||||||
skill_dir = storage.get_custom_skill_dir(name)
|
# Soft archive: flip the lifecycle state. The skill directory stays in
|
||||||
archive_root = Path(storage.get_skills_root_path()) / "custom" / ".archive"
|
# ``custom/<name>/`` untouched so the user can recover it from the
|
||||||
archive_root.mkdir(parents=True, exist_ok=True)
|
# Web UI's archive drawer (which lists skills by state, not by location).
|
||||||
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
||||||
dest = archive_root / f"{name}-{stamp}"
|
|
||||||
try:
|
try:
|
||||||
skill_dir.rename(dest)
|
set_state(name, STATE_ARCHIVED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
curator_log.record("error", f"skill_archive: archive failed for '{name}': {e}", user_id=user_id)
|
curator_log.record("error", f"归档失败:技能 [{name}] — {e}", user_id=user_id)
|
||||||
return f"Archive failed: {e}"
|
return f"Archive failed: {e}"
|
||||||
set_state(name, STATE_ARCHIVED)
|
curator_log.record(
|
||||||
curator_log.record("info", f"Archived '{name}' → custom/.archive/{dest.name}", user_id=user_id)
|
"info",
|
||||||
return f"Archived '{name}' → custom/.archive/{dest.name}."
|
f"已归档(软):[{name}] — 原因:{reason_text} — 文件保留在 custom/{name}/",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return f"Archived '{name}' (reason: {reason_text}; files preserved in custom/{name}/)."
|
||||||
|
|
||||||
return _skill_archive
|
return _skill_archive
|
||||||
|
|
||||||
@@ -501,18 +595,25 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
|||||||
from deerflow.skills import curator_log
|
from deerflow.skills import curator_log
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from langchain.agents import create_agent
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langgraph.prebuilt import create_react_agent
|
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||||
|
|
||||||
model = create_chat_model()
|
model = create_chat_model()
|
||||||
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
|
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
|
||||||
agent = create_react_agent(model, tools)
|
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
|
||||||
|
# (wrong action / missing required args), the exception is converted
|
||||||
|
# into a ToolMessage(status="error") so the LLM can read the error and
|
||||||
|
# retry, instead of aborting the entire review pass.
|
||||||
|
# NOTE: ``create_react_agent`` (langgraph.prebuilt) does NOT accept a
|
||||||
|
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
|
||||||
|
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
|
||||||
|
|
||||||
curator_log.record("info", "LLM review: invoking React agent", user_id=user_id)
|
curator_log.record("info", "LLM 评审:开始调用 React Agent", user_id=user_id)
|
||||||
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
|
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
|
||||||
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
|
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
|
||||||
|
|
||||||
@@ -521,20 +622,20 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
|||||||
if not getattr(msg, "tool_calls", None):
|
if not getattr(msg, "tool_calls", None):
|
||||||
content = getattr(msg, "content", "")
|
content = getattr(msg, "content", "")
|
||||||
if isinstance(content, str) and content.strip():
|
if isinstance(content, str) and content.strip():
|
||||||
curator_log.record("info", "LLM review: agent returned final answer", user_id=user_id)
|
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||||
return content
|
return content
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||||
for block in content
|
for block in content
|
||||||
).strip()
|
).strip()
|
||||||
curator_log.record("info", "LLM review: agent returned final answer", user_id=user_id)
|
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||||
return text
|
return text
|
||||||
curator_log.record("warning", "LLM review: no final assistant message produced", user_id=user_id)
|
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
|
||||||
return ""
|
return ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Curator LLM review failed: %s", e)
|
logger.warning("Curator LLM review failed: %s", e)
|
||||||
curator_log.record("warning", f"LLM review failed: {e}", user_id=user_id)
|
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -563,16 +664,16 @@ def run_curator_review(user_id: str) -> dict:
|
|||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
|
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
|
||||||
curator_log.record("info", f"Curator starting run at {now.isoformat()}", user_id=user_id)
|
curator_log.record("info", f"本次策展开始,启动时间 {now.isoformat()}", user_id=user_id)
|
||||||
|
|
||||||
transition_counters = apply_automatic_transitions(user_id, now)
|
transition_counters = apply_automatic_transitions(user_id, now)
|
||||||
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
|
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
|
||||||
curator_log.record(
|
curator_log.record(
|
||||||
"info",
|
"info",
|
||||||
(
|
(
|
||||||
f"Age-based transitions: checked={transition_counters['checked']}, "
|
f"按年龄自动流转完成:已检查 {transition_counters['checked']} 个,"
|
||||||
f"marked_stale={transition_counters['marked_stale']}, "
|
f"标记陈旧 {transition_counters['marked_stale']} 个,"
|
||||||
f"archived={transition_counters['archived']}"
|
f"自动归档 {transition_counters['archived']} 个"
|
||||||
),
|
),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
@@ -580,7 +681,7 @@ def run_curator_review(user_id: str) -> dict:
|
|||||||
candidate_list = _render_candidate_list(user_id)
|
candidate_list = _render_candidate_list(user_id)
|
||||||
candidate_line_count = 0 if not candidate_list.strip() else len([line for line in candidate_list.splitlines() if line.startswith("- ")])
|
candidate_line_count = 0 if not candidate_list.strip() else len([line for line in candidate_list.splitlines() if line.startswith("- ")])
|
||||||
curator_log.record(
|
curator_log.record(
|
||||||
"info", f"Candidates for LLM review: {candidate_line_count} skill(s)", user_id=user_id
|
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
llm_text = _run_llm_review(candidate_list, user_id=user_id)
|
llm_text = _run_llm_review(candidate_list, user_id=user_id)
|
||||||
@@ -588,36 +689,82 @@ def run_curator_review(user_id: str) -> dict:
|
|||||||
curator_log.record(
|
curator_log.record(
|
||||||
"info",
|
"info",
|
||||||
(
|
(
|
||||||
f"LLM suggestions parsed: consolidations={len(suggestions['consolidations'])}, "
|
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
||||||
f"prunings={len(suggestions['prunings'])}"
|
f"建议删减 {len(suggestions['prunings'])} 项"
|
||||||
),
|
),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
# Surface per-item reasoning so operators can audit *why* each action
|
||||||
|
# was taken, not just the count.
|
||||||
|
for item in suggestions.get("consolidations", []) or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("from", "?")
|
||||||
|
into = item.get("into", "?")
|
||||||
|
reason = (item.get("reason") or "无说明").strip()
|
||||||
|
curator_log.record(
|
||||||
|
"info",
|
||||||
|
f" 合并建议 [{name}] → [{into}]:{reason}",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
for item in suggestions.get("prunings", []) or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("name", "?")
|
||||||
|
reason = (item.get("reason") or "无说明").strip()
|
||||||
|
curator_log.record(
|
||||||
|
"info",
|
||||||
|
f" 删减建议 [{name}]:{reason}",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
state = _read_curator_state(user_id)
|
state = _read_curator_state(user_id)
|
||||||
state["last_run_at"] = now.isoformat()
|
state["last_run_at"] = now.isoformat()
|
||||||
state["run_count"] = state.get("run_count", 0) + 1
|
state["run_count"] = state.get("run_count", 0) + 1
|
||||||
state["last_run_summary"] = (
|
state["last_run_summary"] = (
|
||||||
f"auto: {transition_counters['marked_stale']} marked stale, "
|
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
||||||
f"{transition_counters['archived']} archived; "
|
f"归档 {transition_counters['archived']} 个;"
|
||||||
f"llm: {len(suggestions['consolidations'])} consolidations suggested"
|
f"LLM:建议合并 {len(suggestions['consolidations'])} 项"
|
||||||
)
|
)
|
||||||
state["last_consolidation_suggestions"] = suggestions
|
state["last_consolidation_suggestions"] = suggestions
|
||||||
_write_curator_state(state, user_id)
|
_write_curator_state(state, user_id)
|
||||||
|
|
||||||
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
||||||
curator_log.record("info", f"Curator run complete — {state['last_run_summary']}", user_id=user_id)
|
curator_log.record("info", f"本次策展已完成 — {state['last_run_summary']}", user_id=user_id)
|
||||||
return {"transitions": transition_counters, "suggestions": suggestions}
|
return {"transitions": transition_counters, "suggestions": suggestions}
|
||||||
|
|
||||||
|
|
||||||
|
class _CuratorUser:
|
||||||
|
"""Minimal ``CurrentUser`` protocol implementation for background runs.
|
||||||
|
|
||||||
|
Curator threads are spawned via ``threading.Thread`` (not an asyncio task),
|
||||||
|
so the ``deerflow.runtime.user_context`` ContextVar starts unset. Without
|
||||||
|
a stand-in, any tool the LLM review invokes (e.g. ``skill_manage(create)``)
|
||||||
|
would call ``get_effective_user_id()`` and silently fall back to the
|
||||||
|
``DEFAULT_USER_ID`` sentinel — causing newly-created skills to be owned
|
||||||
|
by ``"default"`` instead of the user the curator is running for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("id",)
|
||||||
|
|
||||||
|
def __init__(self, user_id: str) -> None:
|
||||||
|
self.id = user_id
|
||||||
|
|
||||||
|
|
||||||
def _curator_thread_target(user_id: str) -> None:
|
def _curator_thread_target(user_id: str) -> None:
|
||||||
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
from deerflow.skills import curator_log
|
from deerflow.skills import curator_log
|
||||||
|
|
||||||
|
# Bind the per-thread user context for the entire curator run so every
|
||||||
|
# downstream tool / repository call resolves the correct user_id.
|
||||||
|
token = set_current_user(_CuratorUser(user_id))
|
||||||
try:
|
try:
|
||||||
run_curator_review(user_id)
|
run_curator_review(user_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Curator[%s] background run failed", user_id)
|
logger.exception("Curator[%s] background run failed", user_id)
|
||||||
curator_log.record("error", f"Curator background run failed: {exc}", user_id=user_id)
|
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
|
||||||
|
finally:
|
||||||
|
reset_current_user(token)
|
||||||
|
|
||||||
|
|
||||||
async def maybe_run_curator() -> None:
|
async def maybe_run_curator() -> None:
|
||||||
|
|||||||
+69
-5
@@ -5,11 +5,60 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_active_agent_id(runtime: ToolRuntime | None) -> str | None:
|
||||||
|
"""Extract the agent_id under which the current tool call is running.
|
||||||
|
|
||||||
|
Custom agents pass their id via either the ``context`` dict or the
|
||||||
|
``configurable`` section of the LangGraph runtime config. The default
|
||||||
|
lead agent runs without an explicit agent_id — callers should treat
|
||||||
|
a None return as "no per-agent skills filter applies".
|
||||||
|
"""
|
||||||
|
if runtime is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if getattr(runtime, "context", None):
|
||||||
|
ctx_id = runtime.context.get("agent_id")
|
||||||
|
if ctx_id:
|
||||||
|
return str(ctx_id)
|
||||||
|
cfg = getattr(runtime, "config", None)
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
cid = cfg.get("configurable", {}).get("agent_id")
|
||||||
|
if cid:
|
||||||
|
return str(cid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
||||||
|
"""Return the explicit skill name set declared in an agent's config.yaml.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- ``None`` returned → no agent_id, or config missing — caller should
|
||||||
|
fall back to "all skills visible".
|
||||||
|
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
|
||||||
|
is intentionally empty.
|
||||||
|
- Non-empty set → only these names are visible to this agent.
|
||||||
|
"""
|
||||||
|
if not agent_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from deerflow.config.agents_config import load_agent_config
|
||||||
|
|
||||||
|
cfg = load_agent_config(agent_id)
|
||||||
|
if cfg is None or cfg.skills is None:
|
||||||
|
return None
|
||||||
|
return set(cfg.skills)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@tool("skill_view")
|
@tool("skill_view")
|
||||||
def skill_view_tool(name: str) -> str:
|
def skill_view_tool(name: str) -> str:
|
||||||
"""Read the full SKILL.md content of a skill by name.
|
"""Read the full SKILL.md content of a skill by name.
|
||||||
@@ -48,21 +97,36 @@ def skill_view_tool(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("skill_list")
|
@tool("skill_list")
|
||||||
def skill_list_tool() -> str:
|
def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||||
"""List all available skills with metadata and usage stats. Does NOT read SKILL.md content.
|
"""List skills available to this agent, with metadata and usage stats.
|
||||||
|
Does NOT read SKILL.md content.
|
||||||
|
|
||||||
Returns a JSON array with fields: name, description, category, enabled, state,
|
For a **custom agent**, the result is filtered to skills explicitly
|
||||||
use_count, view_count, patch_count, last_activity_at, pinned, source.
|
declared in the agent's ``.deer-flow/agents/{agent_id}/config.yaml``
|
||||||
|
under the ``skills:`` field — the agent only sees what it was wired to
|
||||||
|
use.
|
||||||
|
|
||||||
|
For the **default lead agent** (no agent_id in runtime), there is no
|
||||||
|
per-agent whitelist and every visible skill is returned.
|
||||||
|
|
||||||
|
Returns a JSON array with fields: name, description, category, enabled,
|
||||||
|
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
||||||
|
source.
|
||||||
"""
|
"""
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
from deerflow.skills.usage import all_entries, latest_activity_at
|
from deerflow.skills.usage import all_entries, latest_activity_at
|
||||||
|
|
||||||
|
agent_id = _resolve_active_agent_id(runtime)
|
||||||
|
allowed = _load_agent_skill_whitelist(agent_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
storage = get_or_new_skill_storage()
|
storage = get_or_new_skill_storage()
|
||||||
skills = storage.load_skills(enabled_only=False)
|
skills = storage.load_skills(enabled_only=False)
|
||||||
usage = all_entries()
|
usage = all_entries()
|
||||||
result = []
|
result = []
|
||||||
for skill in skills:
|
for skill in skills:
|
||||||
|
if allowed is not None and skill.name not in allowed:
|
||||||
|
continue
|
||||||
entry = usage.get(skill.name, {})
|
entry = usage.get(skill.name, {})
|
||||||
result.append({
|
result.append({
|
||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
|
|||||||
+127
@@ -13,6 +13,8 @@ from langgraph.typing import ContextT
|
|||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.mcp.tools import _make_sync_tool_wrapper
|
from deerflow.mcp.tools import _make_sync_tool_wrapper
|
||||||
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.skills.security_scanner import scan_skill_content
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||||
@@ -64,6 +66,85 @@ async def _to_thread(func, /, *args, **kwargs):
|
|||||||
return await asyncio.to_thread(func, *args, **kwargs)
|
return await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_skill_db_registered(store: SkillStore, name: str, user_id: str) -> None:
|
||||||
|
"""Idempotently record an agent-created skill's ownership in the DB.
|
||||||
|
|
||||||
|
Mirrors the ``/api/skills/install-upload`` path so agent-created skills
|
||||||
|
show up in the Web UI listing (which filters custom skills by DB
|
||||||
|
ownership). ``ensure_legacy`` is idempotent — it never overwrites an
|
||||||
|
existing owner — so re-running the create flow on an already-owned skill
|
||||||
|
is a no-op. Failures are swallowed so DB outages can never break the
|
||||||
|
agent's create flow itself.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await store.ensure_legacy(
|
||||||
|
{"name": name, "owner_user_id": user_id, "published": False}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to register skill ownership for %r", name, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_skill_store() -> SkillStore | None:
|
||||||
|
"""Build a ``SkillStore`` backed by the global session factory.
|
||||||
|
|
||||||
|
Returns ``None`` if no DB session factory is configured (e.g. ephemeral
|
||||||
|
test environments), letting the caller skip the registration step
|
||||||
|
silently.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
from deerflow.persistence.skills import make_skill_store
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
if factory is None:
|
||||||
|
return None
|
||||||
|
return make_skill_store(factory)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to build default skill store", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _default_agent_store() -> Any | None:
|
||||||
|
"""Build an ``AgentStore`` backed by the global session factory."""
|
||||||
|
try:
|
||||||
|
from deerflow.persistence.agents import make_agent_store
|
||||||
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
if factory is None:
|
||||||
|
return None
|
||||||
|
return make_agent_store(factory)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to build default agent store", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_agent_may_delete(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
||||||
|
"""Pre-flight ownership check before agent-delete touches the on-disk file.
|
||||||
|
|
||||||
|
LocalSkillStorage doesn't know about users — it deletes anything that
|
||||||
|
matches by name. Without this guard, a malicious or hallucinating agent
|
||||||
|
could destroy another user's skill just by knowing the name. We refuse
|
||||||
|
unless the DB confirms ``user_id`` owns the skill outright.
|
||||||
|
|
||||||
|
Returns the DB record for use in the subsequent cascade step. Raises
|
||||||
|
``ValueError`` (mapped to a tool error visible to the LLM) on refusal.
|
||||||
|
"""
|
||||||
|
record = await store.get_any(name)
|
||||||
|
if record is None:
|
||||||
|
# No DB row — could be a legacy/orphan skill. Refuse rather than
|
||||||
|
# delete blindly; admin can clean up via the Web UI.
|
||||||
|
raise ValueError(
|
||||||
|
f"Refused: skill '{name}' has no ownership record. Ask a human to delete it via the Web UI."
|
||||||
|
)
|
||||||
|
owner = record.get("owner_user_id")
|
||||||
|
if owner is None:
|
||||||
|
raise ValueError(f"Refused: skill '{name}' is built-in and cannot be deleted by an agent.")
|
||||||
|
if owner != user_id:
|
||||||
|
raise ValueError(f"Refused: skill '{name}' is owned by another user.")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
async def _skill_manage_impl(
|
async def _skill_manage_impl(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: ToolRuntime[ContextT, ThreadState],
|
||||||
action: str,
|
action: str,
|
||||||
@@ -124,6 +205,13 @@ async def _skill_manage_impl(
|
|||||||
await _to_thread(mark_agent_created, name)
|
await _to_thread(mark_agent_created, name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Register the new skill in the DB so the web UI listing actually
|
||||||
|
# surfaces it (custom skills without a DB row are filtered out by
|
||||||
|
# ``GET /api/skills``). Resolve the user_id *here* on the main
|
||||||
|
# async stack — contextvars don't propagate into ``to_thread``.
|
||||||
|
store = _default_skill_store()
|
||||||
|
if store is not None:
|
||||||
|
await _ensure_skill_db_registered(store, name, get_effective_user_id())
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return f"Created custom skill '{name}'."
|
return f"Created custom skill '{name}'."
|
||||||
|
|
||||||
@@ -169,6 +257,15 @@ async def _skill_manage_impl(
|
|||||||
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
|
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
|
# Ownership pre-check: LocalSkillStorage deletes purely by name,
|
||||||
|
# so without this an agent could blow away another user's skill
|
||||||
|
# just by knowing the name. Raise BEFORE touching the filesystem.
|
||||||
|
user_id = get_effective_user_id()
|
||||||
|
store = _default_skill_store()
|
||||||
|
record: dict[str, Any] | None = None
|
||||||
|
if store is not None:
|
||||||
|
record = await _assert_agent_may_delete(store, name, user_id)
|
||||||
|
|
||||||
await _to_thread(
|
await _to_thread(
|
||||||
skill_storage.delete_custom_skill,
|
skill_storage.delete_custom_skill,
|
||||||
name,
|
name,
|
||||||
@@ -181,6 +278,36 @@ async def _skill_manage_impl(
|
|||||||
scanner={"decision": "allow", "reason": "Deletion requested."},
|
scanner={"decision": "allow", "reason": "Deletion requested."},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Mirror what ``DELETE /api/skills/custom/{name}`` does: drop the
|
||||||
|
# ``.usage.json`` entry and the DB ownership row, then cascade
|
||||||
|
# the removal to every agent that referenced this skill so
|
||||||
|
# ``config.yaml`` doesn't end up with a dangling reference.
|
||||||
|
# Failures are best-effort — the on-disk delete already happened
|
||||||
|
# and is authoritative.
|
||||||
|
try:
|
||||||
|
from deerflow.skills.usage import forget
|
||||||
|
await _to_thread(forget, name)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
await store.delete(name, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to delete DB row for %r", name, exc_info=True)
|
||||||
|
agent_store = _default_agent_store()
|
||||||
|
if agent_store is not None:
|
||||||
|
try:
|
||||||
|
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||||
|
|
||||||
|
await cascade_skill_unpublished_or_deleted(
|
||||||
|
name,
|
||||||
|
actor_user_id=user_id,
|
||||||
|
operation="delete",
|
||||||
|
skill_owner_user_id=(record.get("owner_user_id") if record else user_id),
|
||||||
|
agent_store=agent_store,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Cascade failed for deleted skill %r", name, exc_info=True)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return f"Deleted custom skill '{name}'."
|
return f"Deleted custom skill '{name}'."
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user